phato.blog

Humble semi-technical blog of Pat Wangrungarun

Creating mini React's Hooks from scratch

12 July 2019 in javascript, react, react-hook · view history

This is very much a transcription of Getting Closure on React Hooks by Shawn Wang. I found the video a very nice way both to brush up a knowledge about Closure and React hooks. So enjoy!

A simple useState()

Let’s start with a simple useState() which does something like this;


const [count, countSet] = useState(0)
console.log(count())  // 0
countSet(1)
console.log(count())  // 1

Here we can use a function to store a private variable.


const useState = (initialState) => {
  let state = initialState

  const getState = () => {
    return state
  }

  const setState = (newState) => {
    state = newState
  }

  return [
    getState,
    setState,
  ]
}

const [count, countSet] = useState(0)
console.log(count())  // 0
countSet(1)
console.log(count())  // 1

React.useState()

To mimic React.useState(), we can wrap out useState() in a React object and it still works the same way.


const React = (() => {
  const useState = (initialState) => {
    let state = initialState

    const getState = () => {
      return state
    }

    const setState = (newState) => {
      state = newState
    }

    return [
      getState,
      setState,
    ]
  }

  return {
    useState,
  }
})()

const [count, countSet] = React.useState(0)
console.log(count())  // 0
countSet(1)
console.log(count())  // 1

Since React renders a component, but we don’t have React.render() yet. So let’s create a component first.


const React = (() => {
  const useState = (initialState) => {
    let state = initialState

    const getState = () => {
      return state
    }

    const setState = (newState) => {
      state = newState
    }

    return [
      getState,
      setState,
    ]
  }

  return {
    useState,
  }
})()

const Component = () => {
  const [count, countSet] = React.useState(0)

  return {
    render: () => {
      console.log({count: count()})
    },
    click: () => {
      countSet(1)
    },
  }
}

const c = new Component()
c.render()  // {count: 0}
c.click()
c.render()  // {count: 1}

This component also has Component.render(), which just logs the output in a console, rather than renders to the DOM.

Now we can implement React.render(), which creates a component, renders it, and also returns the created component (so that we can do the action like App.click).


const React = (() => {
  const useState = (initialState) => {
    let state = initialState

    const getState = () => {
      return state
    }

    const setState = (newState) => {
      state = newState
    }

    return [
      getState,
      setState,
    ]
  }

  const render = (component) => {
    const c = component()
    c.render()
    return c
  }

  return {
    useState,
    render,
  }
})()

const Component = () => {
  const [count, countSet] = React.useState(0)

  return {
    render: () => {
      console.log({count: count()})
    },
    click: () => {
      countSet(1)
    },
  }
}

let App;
App = React.render(Component);  // {count: 0}
App.click()
App = React.render(Component);  // {count: 0}, whoa

Now we have a problem. At line 47, it outputs {count: 0} instead of expected {count: 1}.

This happens because every time we call React.render(), a component will get newly created (const c = component()). This behaviour is essentially what we call re-rendering.

With a newly created component of each re-rendering, a local state in React.useState() is also created, thus we lose the old state.

We can fix this problem by using a closure.


const React = (() => {
  let _state

  const useState = (initialState) => {
    let state = _state || initialState

    const setState = (newState) => {
      _state = newState
    }

    return [
      state,
      setState,
    ]
  }

  const render = (component) => {
    const c = component()
    c.render()
    return c
  }

  return {
    useState,
    render,
  }
})()

const Component = () => {
  const [count, countSet] = React.useState(0)

  return {
    render: () => {
      console.log({count})
    },
    click: () => {
      countSet(1)
    },
  }
}

let App;
App = React.render(Component);  // {count: 0}
App.click()
App = React.render(Component);  // {count: 1}, nice!

By moving state within React.useState() to the outer level, namely _state (line 2). We then create a closure by having a new state within React.useState() closures over _state (line 5).

So the first time React.useState(0) is called _state will be undefined, so closured state will fallback to initialState (undefined || 0) and end up as 0.

Now we can update the closured state properly, App.click() will change _state to 1. Then as the component re-renders, React.useState(0) will get called again, but this time _state is 1 so the closured state will use _state value as 1, it will not fallback to initialState (1 || 0).

Now that we have it work properly, let’s change the click behaviour to increase the count.


const React = (() => {
  let _state

  const useState = (initialState) => {
    let state = _state || initialState

    const setState = (newState) => {
      _state = newState
    }

    return [
      state,
      setState,
    ]
  }

  const render = (component) => {
    const c = component()
    c.render()
    return c
  }

  return {
    useState,
    render,
  }
})()

const Component = () => {
  const [count, countSet] = React.useState(0)

  return {
    render: () => {
      console.log({count})
    },
    click: () => {
      countSet(count + 1)
    },
  }
}

let App;
App = React.render(Component);  // {count: 0}
App.click()
App = React.render(Component);  // {count: 1}
App.click()
App = React.render(Component);  // {count: 2}
App.click()
App = React.render(Component);  // {count: 3}

Let’s see we now want another state in a component to hold a text and name it text. We will output the text in a console and have Component.type() to change it.


const React = (() => {
  let _state

  const useState = (initialState) => {
    let state = _state || initialState

    const setState = (newState) => {
      _state = newState
    }

    return [
      state,
      setState,
    ]
  }

  const render = (component) => {
    const c = component()
    c.render()
    return c
  }

  return {
    render,
    useState,
  }
})()

const Component = () => {
  const [count, countSet] = React.useState(0)
  const [text, textSet] = React.useState('a')

  return {
    render: () => {
      console.log({count, text})
    },
    click: () => {
      countSet(count + 1)
    },
    type: (x) => {
      textSet(x)
    },
  }
}

let App;
App = React.render(Component);  // {count: 0, text: 'a'}
App.click()
App = React.render(Component);  // {count: 1, text: 1}, whoa
App.type('b')
App = React.render(Component);  // {count: 'b', text: 'b'}, whoaa
App.click()
App = React.render(Component);  // {count: "b1", text: "b1"}, whoaaa
App.click()
App = React.render(Component);  // {count: "b11", text: "b11"}, whoaaaa

But it doesn’t work as we expected (which means we expect that it will not work? Or it doesn’t work as we expect? I wonder…). Because React has only one _state, so it can hold only one state.

To fix this, we can store states in a hooks array, with an index to controll an access to it. The hooks will start at index:0, so 0 from count will be stored at index:0, and a from text will be stored at index:1. This works because every time React.useState() is called, index will get incremented by 1.


const React = (() => {
  const hooks = []
  let index = 0

  const useState = (initialState) => {
    let state = hooks[index] || initialState

    const setState = (newState) => {
      hooks[index] = newState
    }

    index++

    return [
      state,
      setState,
    ]
  }

  const render = (component) => {
    const c = component()
    c.render()
    return c
  }

  return {
    render,
    useState,
  }
})()

const Component = () => {
  const [count, countSet] = React.useState(0)
  const [text, textSet] = React.useState('a')

  return {
    render: () => {
      console.log({count, text})
    },
    click: () => {
      countSet(count + 1)
    },
    type: (x) => {
      textSet(x)
    },
  }
}

let App;
App = React.render(Component);  // {count: 0, text: 'a'}
App.click()
App = React.render(Component);  // {count: 1, text: 'a'}, this is ok
App.type('b')
App = React.render(Component);  // {count: 'b', text: 'a'}, um, no
App.click()
App = React.render(Component);  // {count: "b1", text: 'a'}, no
App.click()
App = React.render(Component);  // {count: "b11", text: 'a'}, no

Now we can store both states just fine (line 52), but as we try to set states, it doesn’t work correctly.

This is quite subtle, as we said that index gets incremented constantly, the first time we call React.render(), React.useState() gets called 2 times, thus index will increment from 0 to 2.

Then we call App.click() which subsequently does countSet(count + 1), count is 0 here so it will be countSet(1), given now index is 2, it eventually resulting in hooks[2] = 1.

Now we have hooks: [undefined, undefined, 1], which doesn’t look like what we want. But let’s continue anyway.

The second React.render calls React.useState() another 2 times, incrementing index from 2 to 4.

Then we call App.type('b') which subsequently does textSet('b'), given now index: 4, it eventually resulting in hooks[4] = 'b'.

Now we have hooks: [undefined, undefined, 1, undefined, 'b'].

The third React.render calls React.useState() another 2 times, incrementing index from 4 to 6.

Then we call App.click() which subsequently does countSet(count + 1).

But what is count here? It comes from the third re-rendering’s React.useState(0), by that time index:4 and given let state = hooks[index] || initialState, that is let state = hooks[4] || initialState, which is b!

So we have countSet('b' + 1) which is countSet('b1'), given now index:6, it eventually resulting in hooks[6] = ‘b1’`.

Now we have;


hooks: [undefined, undefined, 1, undefined, 'b', undefined, 'b1']

And so on.

Seems that we have a problem because index gets incremented constantly. How about we reset it every time we re-render the component?


const React = (() => {
  const hooks = []
  let index = 0

  const useState = (initialState) => {
    let state = hooks[index] || initialState

    const setState = (newState) => {
      hooks[index] = newState
    }

    index++

    return [
      state,
      setState,
    ]
  }

  const render = (component) => {
    index = 0
    const c = component()
    c.render()
    return c
  }

  return {
    render,
    useState,
  }
})()

const Component = () => {
  const [count, countSet] = React.useState(0)
  const [text, textSet] = React.useState('a')

  return {
    render: () => {
      console.log({count, text})
    },
    click: () => {
      countSet(count + 1)
    },
    type: (x) => {
      textSet(x)
    },
  }
}

let App;
App = React.render(Component);  // {count: 0, text: 'a'}
App.click()
App = React.render(Component);  // {count: 0, text: 'a'}, whoa, it gets worse
App.type('b')
App = React.render(Component);  // {count: 0, text: 'a'}
App.click()
App = React.render(Component);  // {count: 0, text: 'a'}
App.click()
App = React.render(Component);  // {count: 0, text: 'a'}

The first React.render() calling to React.useState()s still increments index from 0 to 2. The first App.click() sets hooks: [undefined, undefined, 1], okay, still ugly.

Now the second React.render() resets index to 0. That means once React.useState(0) is called, that means let state = hooks[0] || initialState, which is let state = undefined || 0. So count here is 0.

The same goes for React.useState('a') which gives let state = hooks[1] || initialState, which is let state = undefined || 'a'. So text here is a.

Well now the 1 in hooks: [undefined, undefined, 1] doesn’t even get used. We managed to reset index all right, but the reference to a corresponding index of each React.useState() is lost. We somehow need to keep tracking the index for each React.useState().

Closure:


const React = (() => {
  const hooks = []
  let index = 0

  const useState = (initialState) => {
    const _index = index
    let state = hooks[_index] || initialState

    const setState = (newState) => {
      hooks[_index] = newState
    }

    index++

    return [
      state,
      setState,
    ]
  }

  const render = (component) => {
    index = 0
    const c = component()
    c.render()
    return c
  }

  return {
    render,
    useState,
  }
})()

const Component = () => {
  const [count, countSet] = React.useState(0)
  const [text, textSet] = React.useState('a')

  return {
    render: () => {
      console.log({count, text})
    },
    click: () => {
      countSet(count + 1)
    },
    type: (x) => {
      textSet(x)
    },
  }
}

let App;
App = React.render(Component);  // {count: 0, text: 'a'}
App.click()
App = React.render(Component);  // {count: 1, text: 'a'}, yes!
App.type('b')
App = React.render(Component);  // {count: 1, text: 'b'}, yes!
App.click()
App = React.render(Component);  // {count: 2, text: 'b'}
App.click()
App = React.render(Component);  // {count: 3, text: 'b'}

Now let’s see, the first React.render() calling to React.useState()s increments index from 0 to 2 as usual, but now each save its own _index, so _index: 0 for count and _index: 1 for text.

And here comes App.click(), calling countSet(0 + 1) which then is hooks[_index] = 1, which is hooks[0] = 1.

So now hooks: [1], looking good!

The second React.render() then reset index to 0.

Then its calling to React.useState(0) which does const _index = index, which is const _index = 0, still results in _index: 0 for count. Then it does let state = hooks[_index] || initialState, which is let state = hooks[0] || 0, which is let state = 1 || 0, we then have count as 1!

While React.useState('a') will have _index: 1 and while it does let state = hooks[1] || 'a', which is let state = undefined || 'a', we still have text as a.

Then comes App.type('b'), calling textSet('b') which then is hooks[_index] = 'b', which is hooks[1] = 'b'

So now hooks: [1, 'b'], aye, this is what we’re looking for.

The third React.render() then reset index to 0.

React.useState(0) having _index: 0 will look for let state = hooks[0] || 0, which is let state = 1 || 0, so count is 1.

While React.useState('a') having _index: 1 will look for let state = hooks[1] || 0, which is let state = 'b' || 'a', so text is b.

Then comes another App.click(), calling countSet(1 + 1) which then is hooks[_index] = 2, which is hooks[0] = 2

So now hooks: [2, 'b'].

The third React.render() then reset index to 0. React.useState(0) having _index: 0 will look for let state = hooks[0] || 0, which is let state = 2 || 0, so count is 2.

Now React.useState() works exactly what we expected!

React.useEffect()

Moving on to the next hook. Let’s say we would like to have a useEffect() as follow;


const React = (() => {
  const hooks = []
  let index = 0

  const useState = (initialState) => {
    const _index = index
    let state = hooks[_index] || initialState

    const setState = (newState) => {
      hooks[_index] = newState
    }

    index++

    return [
      state,
      setState,
    ]
  }

  const useEffect = (callback) => {
    callback()
  }

  const render = (component) => {
    index = 0
    const c = component()
    c.render()
    return c
  }

  return {
    useState,
    useEffect,
    render,
  }
})()

const Component = () => {
  const [count, countSet] = React.useState(0)
  const [text, textSet] = React.useState('a')

  React.useEffect(() => {
    console.log('useEffect triggered');
  });

  return {
    render: () => {
      console.log({count, text})
    },
    click: () => {
      countSet(count + 1)
    },
    type: (x) => {
      textSet(x)
    },
  }
}

let App;
App = React.render(Component);
// useEffect triggered 
// {count: 0, text: 'a'}

App.click()
App = React.render(Component);
// useEffect triggered 
// {count: 1, text: 'a'}

App.type('b')
App = React.render(Component);
// useEffect triggered 
// {count: 1, text: 'b'}

App.click()
App = React.render(Component);
// useEffect triggered 
// {count: 2, text: 'b'}

App.click()
App = React.render(Component);
// useEffect triggered 
// {count: 3, text: 'b'}

By this way useEffect()’s callback will run every time a component is created. Let’s try to have a dependency array.


const React = (() => {
  const hooks = []
  let index = 0

  const useState = (initialState) => {
    const _index = index
    let state = hooks[_index] || initialState

    const setState = (newState) => {
      hooks[_index] = newState
    }

    index++

    return [
      state,
      setState,
    ]
  }

  const useEffect = (callback, depArray) => {
    let hasChanged = true
    if (hasChanged) callback()
  }

  const render = (component) => {
    index = 0
    const c = component()
    c.render()
    return c
  }

  return {
    useState,
    useEffect,
    render,
  }
})()

const Component = () => {
  const [count, countSet] = React.useState(0)
  const [text, textSet] = React.useState('a')

  React.useEffect(() => {
    console.log('useEffect triggered');
  }, [count]);

  return {
    render: () => {
      console.log({count, text})
    },
    click: () => {
      countSet(count + 1)
    },
    type: (x) => {
      textSet(x)
    },
  }
}

let App;
App = React.render(Component);
// useEffect triggered 
// {count: 0, text: 'a'}

App.click()
App = React.render(Component);
// useEffect triggered 
// {count: 1, text: 'a'}

App.type('b')
App = React.render(Component);
// useEffect triggered 
// {count: 1, text: 'b'}

App.click()
App = React.render(Component);
// useEffect triggered 
// {count: 2, text: 'b'}

App.click()
App = React.render(Component);
// useEffect triggered 
// {count: 3, text: 'b'}

We just pass in depArray as another argument and hard-coded hasChanged as true, so nothing has changed here.

So where should we keep tracking changes? Let’s see, we already use the hooks array to keep states, why not also use it to track changes as well?


const React = (() => {
  const hooks = []
  let index = 0

  const useState = (initialState) => {
    const _index = index
    let state = hooks[_index] || initialState

    const setState = (newState) => {
      hooks[_index] = newState
    }

    index++

    return [
      state,
      setState,
    ]
  }

  const useEffect = (callback, depArray) => {
    const oldDeps = hooks[index]
    let hasChanged = true

    console.log('useEffect: ', oldDeps, depArray)

    if (hasChanged) callback()
    
    hooks[index] = depArray
    index++
  }

  const render = (component) => {
    index = 0
    const c = component()
    c.render()
    return c
  }

  return {
    useState,
    useEffect,
    render,
  }
})()

const Component = () => {
  const [count, countSet] = React.useState(0)
  const [text, textSet] = React.useState('a')

  React.useEffect(() => {
    console.log('useEffect triggered');
  }, [count]);

  return {
    render: () => {
      console.log({count, text})
    },
    click: () => {
      countSet(count + 1)
    },
    type: (x) => {
      textSet(x)
    },
  }
}

let App;
App = React.render(Component);
// useEffect: undefined [0]
// useEffect triggered
// {count: 0, text: 'a'}

App.click()
App = React.render(Component);
// useEffect: [0] [1]
// useEffect triggered
// {count: 1, text: 'a'}

App.type('b')
App = React.render(Component);
// useEffect: [1] [1]
// useEffect triggered, but it shouldn't be
// {count: 1, text: 'b'}

App.click()
App = React.render(Component);
// useEffect: [1] [2]
// useEffect triggered
// {count: 2, text: 'b'}

App.click()
App = React.render(Component);
// useEffect: [2] [3]
// useEffect triggered
// {count: 3, text: 'b'}

We now have both existing dependency array (oldDeps) and current dependency array (depArray). For now let’s just log both of them (line 25). And we then replace the dependency array for next comparison. We also need to increment index the same fashion as useState().

So for the first rendering, hooks[2] is undefined, so is oldDeps: undefined, and depArray: [1], then useEffect()’s callback should be triggered, and hooks[2] is to become [1].

As for the third rendering, hooks[2]: [1] and depArray: [1], so useEffect()’s callback shouldn’t be triggered.

Next, we will compare both dependencies and determine hasChanged properly.


const React = (() => {
  const hooks = []
  let index = 0

  const useState = (initialState) => {
    const _index = index
    let state = hooks[_index] || initialState

    const setState = (newState) => {
      hooks[_index] = newState
    }

    index++

    return [
      state,
      setState,
    ]
  }

  const useEffect = (callback, depArray) => {
    const oldDeps = hooks[index]
    let hasChanged = true

    console.log('useEffect: ', oldDeps, depArray)

    if (oldDeps) {
      hasChanged = depArray.some((x, i) => x != oldDeps[i])
    }

    if (hasChanged) callback()

    hooks[index] = depArray
    index++
  }

  const render = (component) => {
    index = 0
    const c = component()
    c.render()
    return c
  }

  return {
    useState,
    useEffect,
    render,
  }
})()

const Component = () => {
  const [count, countSet] = React.useState(0)
  const [text, textSet] = React.useState('a')

  React.useEffect(() => {
    console.log('useEffect triggered');
  }, [count]);

  return {
    render: () => {
      console.log({count, text})
    },
    click: () => {
      countSet(count + 1)
    },
    type: (x) => {
      textSet(x)
    },
  }
}

let App;
App = React.render(Component);
// useEffect: undefined [0]
// useEffect triggered 
// {count: 0, text: 'a'}

App.click()
App = React.render(Component);
// useEffect: [0] [1]
// useEffect triggered 
// {count: 1, text: 'a'}

App.type('b')
App = React.render(Component);
// useEffect: [1] [1], here useEffect won't be triggered
// {count: 1, text: 'b'}

App.click()
App = React.render(Component);
// useEffect: [1] [2]
// useEffect triggered 
// {count: 2, text: 'b'}

App.click()
App = React.render(Component);
// useEffect: [2] [3]
// useEffect triggered 
// {count: 3, text: 'b'}

Note that this is a simple comparison but it should help you get the picture. We will compare each items in oldDeps and depArray by its respective position, i.e. index, if any of them is different, then useEffect()’s callback will be triggered.

For the second rendering it will be 0 != 1, so useEffect()’s callback will be triggered. And for the third rendering it will be 1 != 1, so useEffect()’s callback will not be triggered.

Let’s try also having text in depArray as well.


const React = (() => {
  const hooks = []
  let index = 0

  const useState = (initialState) => {
    const _index = index
    let state = hooks[_index] || initialState

    const setState = (newState) => {
      hooks[_index] = newState
    }

    index++

    return [
      state,
      setState,
    ]
  }

  const useEffect = (callback, depArray) => {
    const oldDeps = hooks[index]
    let hasChanged = true

    console.log('useEffect: ', oldDeps, depArray)

    if (oldDeps) {
      hasChanged = depArray.some((x, i) => x != oldDeps[i])
    }

    if (hasChanged) callback()

    hooks[index] = depArray
    index++
  }

  const render = (component) => {
    index = 0
    const c = component()
    c.render()
    return c
  }

  return {
    useState,
    useEffect,
    render,
  }
})()

const Component = () => {
  const [count, countSet] = React.useState(0)
  const [text, textSet] = React.useState('a')

  React.useEffect(() => {
    console.log('useEffect triggered');
  }, [count, text]);

  return {
    render: () => {
      console.log({count, text})
    },
    click: () => {
      countSet(count + 1)
    },
    type: (x) => {
      textSet(x)
    },
  }
}

let App;
App = React.render(Component);
// useEffect: undefined [0, 'a']
// useEffect triggered 
// {count: 0, text: 'a'}

App.click()
App = React.render(Component);
// useEffect: [0, 'a'] [1, 'a']
// useEffect triggered 
// {count: 1, text: 'a'}

App.type('b')
App = React.render(Component);
// useEffect: [1, 'a'] [1, 'b']
// useEffect triggered, here useEffect will once again triggered
// {count: 1, text: 'b'}

App.click()
App = React.render(Component);
// useEffect: [1, 'b'] [2, 'b']
// useEffect triggered 
// {count: 2, text: 'b'}

App.click()
App = React.render(Component);
// useEffect: [2, 'b'] [3, 'b']
// useEffect triggered 
// {count: 3, text: 'b'}

App.type('c')
App = React.render(Component);
// useEffect: [3, 'b'] [3, 'c']
// useEffect triggered 
// {count: 3, text: "c"}

Now for the second rendering it will be 0 != 1 or 'a' != 'a', so useEffect()’s callback will be triggered. And for the third rendering it will be 1 != 1 or 'a' != 'b', so useEffect()’s callback will also be triggered.

We can change the order of items in depArray and it still works, because each items in oldDeps and depArray will be compared respectively.


const React = (() => {
  const hooks = []
  let index = 0

  const useState = (initialState) => {
    const _index = index
    let state = hooks[_index] || initialState

    const setState = (newState) => {
      hooks[_index] = newState
    }

    index++

    return [
      state,
      setState,
    ]
  }

  const useEffect = (callback, depArray) => {
    const oldDeps = hooks[index]
    let hasChanged = true

    console.log('useEffect: ', oldDeps, depArray)

    if (oldDeps) {
      hasChanged = depArray.some((x, i) => x != oldDeps[i])
    }

    if (hasChanged) callback()

    hooks[index] = depArray
    index++
  }

  const render = (component) => {
    index = 0
    const c = component()
    c.render()
    return c
  }

  return {
    useState,
    useEffect,
    render,
  }
})()

const Component = () => {
  const [count, countSet] = React.useState(0)
  const [text, textSet] = React.useState('a')

  React.useEffect(() => {
    console.log('useEffect triggered');
  }, [text, count]);

  return {
    render: () => {
      console.log({count, text})
    },
    click: () => {
      countSet(count + 1)
    },
    type: (x) => {
      textSet(x)
    },
  }
}

let App;
App = React.render(Component);
// useEffect: undefined ['a', 0]
// useEffect triggered 
// {count: 0, text: 'a'}

App.click()
App = React.render(Component);
// useEffect: ['a', 0] ['a', 1]
// useEffect triggered 
// {count: 1, text: 'a'}

App.type('b')
App = React.render(Component);
// useEffect: ['a', 1] ['b', 1]
// useEffect triggered
// {count: 1, text: 'b'}

And that’s pretty much every thing now. Thanks again to Shawn Wang.