iTranslated by AI
Avoiding common useState pitfalls
useState for Linking Values to Components
In React, the useState hook is commonly used when you want a component to hold a value.
If you treat the state provided by this useState like a regular variable, you will often run into pitfalls, such as the value not updating as expected or getting reset unexpectedly.
Let's take a look at how useState works to maintain and update values.
What is useState?
useState provides a state, which is a value held by the component, and a setter function (set function) to modify that state.
const [num setNum] = useState(0)
As shown in the code example above, calling useState and passing an initial value (the default is undefined if omitted) returns the state and the setter function as a tuple. (The [state, setState] syntax uses array destructuring to receive the values.)
To update the value of state, you pass the new value to the setter function.
You can also pass an updater function to the setter function; this updater function receives the current state value (from React) as an argument.
setNum(((n) => n + 1))
Why is useState Necessary?
It might seem that you could just manage values using local variables with let or const. However, because React goes through a process called "rendering" to update the screen, local variables are not preserved between renders and are reset, making it impossible to effectively link them to the component. This is why useState is required to maintain component values across renders.
What is a "Render" Anyway?
In React, rendering refers to React calling your component.
(React calls the component to create a virtual DOM tree, calculates the difference from the current DOM tree, and updates the real DOM only where changes occurred.)
A render instruction is added to the queue when props from a parent change, forceUpdate() is called, or state is updated via a setter function.
Even if you declare a local variable inside a component to link a value to it, the component is called again during a render and the local variable is recreated, causing it to be reset.
Pitfall 1: state Doesn't Update Immediately After Calling the Setter Function?
In the code example above, the setter function is executed twice.
If you think of the setter function as assigning a value to a state variable, it might seem that num would become 1 at the first execution setNum(num + 1) and then 3 at the second execution setNum(num + 2). However, if you actually press the button, you'll see it increments by 2.
This is because the setter function doesn't change the state value immediately; instead, it adds an instruction to a queue to change the state value for the next render.
All state change instructions added to the queue are processed to determine the state value for the next render.
Setter Functions Set the state Value for the Next Render
The state value returned by useState is the argument passed to useState during the initial render. In subsequent renders, it returns the value set by the setter function (during the previous render).
The setter function is a function that sets the state value for the next render, and the state value itself remains unchanged during that specific render.
When values are set by multiple setter functions, the value resulting from all those setting operations is returned as the state value in the next render.
In the previous code example, the setter function was executed twice as follows:
const clickHandler = () => {
setNum(num + 1);
setNum(num + 2);
};
This happens because after "setting a value by adding 1 to the state value num at this render," it "sets a value by adding 2 to the state value num at this render." Consequently, num + 2 is ultimately set as the state value for the next render.
Because it is processed this way, the value displayed on the screen after pressing the button did not become num + 3.
Avoiding the Pitfall by Passing an Updater Function
Instead of using the state value from the time of the render, if you want to use the value at the moment the process is being executed, you can solve this by preparing a separate variable, performing the calculation, and then passing it to the setter function.
A more concise way to achieve this is to pass an updater function to the setter function.
By doing something like setNum((n) => n + 1), you can receive the state value scheduled for the next render as the argument n, and the return value n + 1 is newly set as the state value for the next render.
Using this method, the previous code example can be rewritten as follows:
By clicking the button, you should see the value increment by 3.
setState Adds Update Instructions to the Queue to Determine the Next Render's state Value
When multiple state updates occur, React batches them together rather than performing a separate render for each one (with some exceptions, such as when intentional events like clicks occur multiple times).
In the previous code example, the updates from the first queue setNum((n) => n + 1) and the second queue setNum((n) => n + 2) are processed based on the current render's state (the value returned by useState).
A call like setNum(num + 1) can also be interpreted as passing an updater function like setNum((n) => num + 1). If you call setNum(num + 2) after setNum(num + 1), the value set by the first call isn't used by the next one, so the process proceeds as if it never happened.
Pitfall 2: The Screen Doesn't Update Even After Using the Setter Function?
Normally, calling a setter function to set the state for the next render triggers a re-render.
However, in cases like the following, a re-render does not occur.
If you click the button, you will see in the console that the num property of both initialObj and state has indeed changed, but the value on the screen remains 123.
The cause is that the value initialObj passed as an argument to the setter function has not changed from its value in the previous render, so no re-render instruction was added.
The Setter Function Detects State Changes Using Object.is()
The setter function determines whether the state has changed since the previous render by using Object.is().
Object.is() performs almost the same comparison as the strict equality operator ===, but it differs in that it does not treat 0 and -0 as equal, and it does treat NaN as equal to NaN.
In the previous example, although the value of the property in initialObj had changed, the state was not detected as having changed, and thus no re-render occurred.
The variable initialObj stores a reference to an object (the memory address where the object is stored). Even if the property values change, the reference itself does not, which is why the change in state was not detected.
Avoiding the Pitfall by Creating a New Object
Since changing a property of the same object as the current state will not be detected as a change, you can ensure detection by creating a new object.
By using spread syntax and overriding property values, you can describe state changes in a way that is also easy for readers to understand.
Side Note: Why Can't useState be Used in Loops, Conditions, or Nested Functions?
Hooks such as useState throw an error when called inside loops, conditions, or nested functions. Why is this restriction imposed?
The reason is that React identifies hooks within a component based on the order in which they are called.
Since loops or conditional branches can cause the order of hook calls to change between component renders, calling hooks within these features is restricted.
Discussion