Press enter or click to view image in full size
React’s functional components introduce a powerful yet unconventional programming model — one that can create subtle bugs even for experienced developers. To truly master React, it’s essential to understand how it renders components and manages state. In this article, I take a detailed look at the useState hook. I'll explain what a component instance is, how state values persist across renders, and why these details matter for building reliable components and unlocking advanced React techniques.
Let’s start with a simple hook function that has a single use of state. Assume we have an Input component that emits a plain string in the onChange handler.
function EchoText() {
const [text, setText] = useState("Initial") return (
<div>
<Input value={text} onChange={setText} />
<p>{text}</p>
</div>
)
}
To understand what useState does, we need to first understand how this component function, EchoText will be called. React component functions are ultimately regular JavaScript functions. The special syntax for elements is compiled to a jsx function — see the appendix “Compiled JSX” for an example. Other than that though, the rest of the function remains the same. React functions like useState, at the surface level, behave like normal JavaScript functions. But clearly they do something special. Understanding how is important to writing proper React code.
Rendering Nodes
Taking a step back, we know some higher component has emitted this one, of the simple form <EchoText/>. This goes all the way to a root level render function that establishes the base of the tree — somewhere you’ve likely forgotten about in your top-level index file. There’s nothing about the function that indicates it’s a component until you attempt to render it.
It can be tempting to think of function components as similar to classes and instances. For example, assume that our EchoText is a class, and React instantiates it with inst = new EchoText(), then later calls inst.render(). We could then think of useState as creating a member property, and useCallback creating member functions. This is indeed how class components used to work. And while logically a similar relationship exists with hook functions, the implementation is distinct, and the class analogy might be an impediment to a true understanding of what’s happening. React does instantiate components, but that’s unrelated to JavaScript’s class system.
When a component is rendered, the higher level component that renders <EchoText /> is creating an instance of that component. An “instance” here refers to a real object React stores internally. We have no access to this instance except via functions like useState. When our EchoText function is called, it’s called in the context of a particular instance, which is why its state persists.
To understand this relationship, let’s look at some theoretical React code. I’m going to use the prefix TR, or tr to match name-case rules, to refer to a “theoretical React” function type, class, or functionality. React behaves as-if it works this way, but the actual implementation varies. Indeed, the system hides how it works beyond the as-is theoretical.
So, this is our theoretical code for how our component is instantiated and rendered.
// some higher level component does `return <EchoText/>`
const renderNode = ... ; // the result of <EchoText />, or `jsx( EchoText )`// So React creates a new object to store data for that instance.
const node = new TRNodeInstance()// And tracks it somehow...// Then, to render the component, it stores that instance in global memory
trComponentInstance = node// Then it calls our function to render it, where `function` here will be `EchoText`
const renderResult = renderNode.function()
The key point to get from this is that React has created an instance for a particular rendering our component. This instance is some generic React data structure: TRNodeInstance. When our EchoText function is called, React will have stored this instance in the global variable trComponentInstance. Our function will store and retrieve data from this object, via React’s hook functions. We don’t directly know about this instance, or where it’s exactly stored, but the React hook functions do.
For this article, I will mostly skip how React tracks the ‘TRNodeInstance’ across several render phases. The code above only shows the creation of a new instance. We’re going to have to accept as given that during a rerender the same logical node will get mapped to the same TRNodeInstance.
We will give them ids to help track them though. For example, we create multiple instances in the below code.
function Higher() {
return <div>
<EchoText/>
<EchoText/>
</div>
}On the first rendering of this Higher component, we create two instances of the EchoText component, let's give them ids of echo1 and echo2. When Higher is rendered again, it will reuse the echo1 and echo2 instances. The trComponentInstance is the same object across rerenders of the same logical component in the node tree.
useState
Go back to the EchoText function. Nothing special happens until we call useState. This is called like a normal JavaScript function. Inside, however, it will look at the trComponentInstance global variable, using it to register its state and setters.
Our code:
function EchoText() {
const [text, setText] = useState("Initial")
...
}In the React library:
// Theoretical, not actual React code. useState works "as-if" it does the below
function useState(initValue) {
const thisInstance = trComponentInstance
const index = thisInstance.nextStateIndex++ // on the first call we need to initialize, on other calls keep as is
if( thisInstance.firstRender ) {
thisInstance.stateValue[index] = initValue
thisInstance.stateSetter[index] = (newValue) => {
thisInstance.stateValue[index] = newValue
trDirtyComponentSet.add( thisInstance.id )
}
}
return [thisInstance.stateValue[index], thisInstance.stateSetter[index]]
}
Let’s break this down line-by-line.
const thisInstance = trComponentInstanceThis takes a local copy of trComponentInstance, ensuring its stable for the entire body of our function. This will matter most when we define the setter.
const index = thisInstance.nextStateIndex++React component functions can have several calls to useState, so each one needs a unique index. It seems sensible to start at 0 and count upwards, which then allows the user a simple array to track the values. You can see that the order in which the calls to useState are made is important due to this indexing. This is why hook functions can’t be called conditionally. If not all the same number of hooks are called, the logically same state variables would be assigned a different index, and get different values.
Why an index? Recall that this is normal JavaScript code. React has no special knowledge about the calls to
useStateother than the order in which they’re called. An index is the simplest way to track this call order.If React had wanted to, it could have provided a named state function, like
useState( “text”, “Initial” )and use the name as a lookup key. This would be less efficient, but also potentially confusing, as that name wouldn’t have to be related to the variable we assign it to, for examplevar [cakeType, setCakeType] = useState(“car”, CakeType.Chocolate ). With names, the order of the declarations wouldn’t matter; thus conditional state would be possible.
if( thisInstance.firstRender ) {Component functions will be called multiple times: each time the parent component renders, the child component functions could be called. Let’s assume the first render call is tracked explicitly with a firstRender variable on the instance. For each call to EchoText, nextStateIndex will be reset to zero, but all other properties will remain the same, and firstRender will be true only on the first call.
It’s critical to understanding React that our component functions, and these hooks, are called again for each render. These have to be understood as following normal JavaScript flow rules. In our EchoText component, the illusion of a persistent text state value is created by execution of the code in the same order, against the same trComponentInstance. I said components behave something like a class and instance, but this is the first sign that their implementation is anything but.
// if( thisInstance.firstRender ) { ...
thisInstance.stateValue[index] = initValueIn this line, we see a stateValue array that will track all the variables for an instance. Here we are setting the value for this particular state, using the index. The initValue is whatever is passed to the useState function. This is the first place where React can get confusing. React will only use the value provided to ‘useState’ in the first render pass. This instance will ignore all future values.
// if( thisInstance.firstRender ) { ...
thisInstance.stateSetter[index] = (newValue) => {
thisInstance.stateValue[index] = newValueWe also need a way to set the state variable, something to pass to the Input component. We create a simple lambda function to do this. This is where the local thisInstance is vital. This setter function can be called anywhere from our entire code base. When it’s called, we can’t rely on trComponentInstance having the right value. By copying to a local variable, we have ensured that this lambda function always refers to the same instance.
Having the same instance and the same index, let’s update the state value by copying a new value into the stateValue array.
We store the setter in a stateSetter. This ensures that for the future render passes, the returned setter is the exact same lambda function, and not just a new function that does the same thing. This type of stability is paramount to React’s processing.
// if( thisInstance.firstRender ) { ...
trDirtyComponentSet.add( thisInstance.id )
}Only setting the state value would not be enough. stateValue is, after all, just some array in memory. React needs a way to know that it’s been updated. While it could in theory just scan all components for changes on each render, that’d be horribly slow. It must have a way of tracking particular dirty nodes. In this line, we’re telling that system that thisInstance is dirty and needs to be re-rendered. The Set is likely a gross oversimplification of how this dirty tracking actually behaves.
return [thisInstance.stateValue[index], thisInstance.stateSetter[index]]Finally, pass the current value and the setter back to the caller. Again, every time EchoText is rendered, the function is called. The persistence of values, the thing that makes it look somewhat like class with properties, is due to useState tracking and returning the same values for a particular trComponentInstance.
return (
<div>
<Input value={text} onChange={setText} />
<span>{text}</span>
</div>
)
}After the useState call, we use those values in the node-tree we return. Every time our function is called, we create a new node tree. This node-tree will contain the state values at the time the function was executed. It doesn’t know that text is a state value, or that setText is a state setter. It just sees variables. They could be other local variables or global variables.
The key feature of React is the ability to take these unique node-trees and compare them to the node tree the function produced previously, looking for differences. It then optimizes the rendering by excluding nodes that do not need to be updated. This is where the stability of setText is so important. It’s not enough to just do the same thing; it must be the exact same function in order for React to avoid re-rendering Input.
It’s not defined precisely when the final rendering and calling of other component functions is done. This is where some of the difficulty in using React comes from, in particular for the other hook functions, such as useMemo.
Conclusion
Understanding how useState works under the hood reveals a lot about React’s programming paradigm. Hooks aren’t magical — they operate within plain JavaScript rules, relying on consistent execution order and internal tracking of component instances.
This article gives us the basic understanding of hooks, via the useState function. In future articles I’ll look at the useMemo and useCallback hook functions.
Appendix: Compiled JSX
In the introduction, I showed this component, in a JSX file.
function EchoText() {
const [text, setText] = useState("Initial") return (
<div>
<Input value={text} onChange={setText} />
<p>{text}</p>
</div>
)
}
Using elements directly in the code distinguishes JSX from plain JS code. When transformed, it’ll look like the below. It can vary depending on the translator and version.
function EchoText() {
const [text, setText] = useState("Initial");
return jsxs("div", {
children: [jsx(Input, {
value: text,
onChange: setText
}), jsx("p", {
children: text
})]
});
}The transformation is relatively simple, and changes only the node tree part. The code keeps the rest of the JavaScript unchanged. This means that functions like useState have no special support; they’re implemented as normal JavaScript functions.
You can use Babel’s tool to see the above transformations.
Appendix: useState Initial Value
I mentioned that the value passed to useState is used only on the initial render pass. This can lead to defects.
One issue I have encountered involves component instability. The concept of “initial render” can be fuzzy if the application’s node tree isn’t stable. Remounting a component results in a new instance, and consequently, another first render pass. So it’s possible that code that is wrong can work in an unstable call tree.
function ServerPost( props: {
message: string
}) {
const [toSend, setToSend] = useState( createTextMessage( props.message ) )
const onClick = useCallback( () => apiPost( toSend ), [ toSend] ) return <Button onClick={onClick}>Post "{props.message}"</Button>
}
The ServerPost component will render with the correct current message. The callback is also correct, as it lists toSend as a dependency. However, only the first render pass uses the value of ‘props.message’. If it changes, and the user clicks the button, the wrong message can be sent.
It’s an obvious error, but how could it go unnoticed? It’s possible that it’s sitting inside an unstable render tree and gets remounted every time the message changes. Or perhaps the message rarely changes, so it usually works.
Why did a coder make this error? Could just be a momentary mental blip, a confusion between a useMemo and useState. Or perhaps they come from a framework where the initial value is used until an explicit call to setToSend is made. The actual errors I’ve seen are more complex than this. I’ve seen it more often in relation to Redux selectors, which are complex wrappers to useState, where there’s more potential for things to go wrong. But that’s a whole other topic.