unModified()

Break stuff. Now.

React Hooks Has Piqued My Interest

March 3, 2021

It's been a while since something has piqued my interest. The last time this happened, it was when I was looking into Redux. After understanding how the mechanism works, it was smooth sailing from there onwards, regardless of what Redux-like library I was facing. I even ended up writing a 40-liner, bare-bones version of the mechanism in vanilla JavaScript (which I'll write about as soon as I complete one of the apps that use it).

This time, it's React hooks.

const MyComponent = () => {
  const [counter, setCounter] = useState(0)

  return (
    <div>
      <p>count: {counter}</p>
      <button onClick={() => setCounter(counter + 1)}>Add</button>
    </div>
  )
}

While most people love React's syntax, how it's is so amazing, what things they can build with it, and how it would improve their developer experience, I...

...want to know what makes it tick. 😈

The syntax makes sense, but it's a weird way of declaring and updating local state. With other frameworks, your app is essentially a big tree of component class instances. Local state is stored on these instances. When a section of your app needs to render, e.g. when local state updates, the framework would identify the affected instances, and update the interfaces they're responsible for using the local state values and the compiled template as guide.

In React, components are functions that return JSX that represent the component structure. Your app is just a big composition of JSX (aka the "virtual DOM"). When your app needs to render, it invokes the component functions to recompose the virtual DOM, which will then be used to update the real DOM. But here's the thing: if React components are referentially transparent functions that called on every render, there can't be a "local state". And any changes in the JSX would just be the result of prop changes.

And yet we have useState which appears to magically persist counter across calls to MyComponent. How in the world is it doing that? Without anything else to go by, and assuming useState is also referentially transparent, useState should at least look like this:

const useState = initialValue => {
  let value = initialValue
  const setter = newValue => { value = newValue }

  return [value, setter]
}

This doesn't help at all. If I call useState implemented this way, 1) it would always return initialValue as the value and 2) setter does nothing other than set the internal variable value to newValue. When we call MyComponent, the state for the counter will always be initialValue regardless.

But what if useState is not referentially transparent all? What if it's cheating?

Well, that's how this blog post and this Stack Overflow answer explain it. In order for useState to persist changes across renders and trigger a render, it would have to be impure and cause side-effects. In this case, useState would have to track the state values in some storage outside the components, persisting across calls. And its setter would have to cause React to render the application.

Without local state and only relying on props, a component is quite literally a function of its props. That is, given the same props, invoking the component function it should yield the same JSX. But if you add local state, it breaks the rules of referential transparency. Components are no longer deterministic, as calling the component with the same props may yield different results depending on the values coming from useState, which depends on what values were set prior to the call.

Knowing this, React hooks... is somewhat similar to Redux. useState does ressemble slice functions, with the returned value being a slice of the state object. The setter reminds me of dispatch with only the payload and without the action name. And the state object? We just talked about that two paragraphs back, the storage outside the components. But unlike Redux, you don't explicitly model it or compute the next state; React does that for you.

However, all of this just resulted in more questions than answers, which is always good. For instance, where is does the state object tracking the local state reside? How does it know which value goes to which component calling useState? How does this state storage look like? What data structure is this state object using? How does useState know which React app to operate on in the event that there is more than one on the page?

It's an interesting topic, one that would like to learn more and reproduce myself in demo scale to fully understand the underlying mechanism. And to do that... I need get some sleep. 😴


Update = "I've done some more digging on the subject of how React keeps track of state. Read several blogs (especially this one), got the gist of how it conceptually works, but still have more questions about it. That is, without digging into React source code."

In summary, state is stored somewhere in React and not in your application. While you use useState in your components like you were declaring variables, the value you initially and eventually set are stored somewhere in React. This is very different compared to other frameworks where you store your data in the component instance.

As to how they're stored, React keeps state in a data structure that's heavily reliant on the call order of the hooks and component functions. Because when you called the hook determines its position in storage. That's why they've formulated one of the two rules when using hooks: always call it at the top level and never conditionally. This ensures that the hooks are always called in the same order between two renders, allowing hooks to get you the right data.

But this explanation still leaves me with one big question. If call order matters, what about hook calls inside components that are conditionally rendered? If one render causes a component to render (and therefore call its hooks), but the next render causes a component not to render (and therefore not call its hooks), what happens to the call order for the components invoked after it? How would the succeeding components know to skip X steps in the data store to get to their data? Does React ever clean up the data store for components no longer rendered?

Something tells me there is more to just call order and storage. I just feel it.