React’s useState hook details

Table of contents

In this post, you’ll see some details with the useState hook. More specifically, you’ll learn about:

  • The difference between lazy initialization and passing an initial value.
  • The difference between the functional and the regular setState.
  • The fact that the setState function remains the same during renders.

What’s the difference between lazy initialization and passing an initial value to useState hook?

Consider a typical useState hook (the initialValue represents a number or a string value):

const [value, setValue] = useState(initialValue);

If you declare a useState hook this way, React will use the initialValue for the first render and ignore it for subsequent renders. Ignoring the initial value on subsequent renders makes sense because you want to be able to change the state in your code.

If you calculate the initial value with an expensive function, though, React will still run the expensive function on every render. It will just discard the result. An example of what I’m talking about:

const [value, setValue] = useState(someExpensiveComputation(input));

This behavior isn’t specific to React, though. In the example above, you invoke a function that returns your initial state, so the JavaScript engine itself has to evaluate that function call to get the resulting value. If you don’t want to evaluate the expensive function on every render, you can use lazy initialization and pass a function as the first argument to the useState, as shown below:

const [value, setValue] = useState(() =>
  someExpensiveComputation(input)
);

This is different because you pass a function, not the result of a function call. You can also pass a function reference if your expensive function doesn’t need a parameter to make the code less verbose:

const [value, setValue] = useState(someExpensiveComputation);

You can observe this useState behavior in this CodeSanbox (comment / uncomment the lines).

What’s the difference between the functional and the regular setState?

In the following snippet, I use the useState hook to store and update a counter:

const [counter, setCounter] = useState(0);

The most straightforward way to update your counter state is to pass a value or an expression to the setState function (I call this regular form):

const [counter, setCounter] = useState(0);
// somewhere inside an event listener
setCounter(counter + 1);

The alternative is to pass a function (aka updater function) that has the previous state as a parameter and returns the new state (I call this functional form):

const [counter, setCounter] = useState(0);
// somewhere inside an event listener
setCounter((prev) => prev + 1);

Most of the times, these two forms of setState do the same thing. They start to differ if you call them inside an asynchronous function or multiple times inside the same function.

Let’s explain what happens when you call them inside an asynchronous function. That function can be a setTimeout, a promise.then block, or a requestAnimationFrame. For example:

const [counter, setCounter] = useState(0);
// somewhere inside an event listener
setTimeout(() => {
  setCounter(counter + 1);
}, 1000);

If you use the regular form to increase the counter inside the setTimeout, and then you increase the counter again by 1 in a synchronous function (or if you update 2 times quickly with the asynchronous function), the counter will remain at 1. This happens because the setTimeout callback captures the value of the counter at the time you create it. So it sees setCounter(0 + 1) and not setCounter(currentValue + 1). An important detail to remember here is that we get a new counter and function on every render. The asynchronous function closes over the counter value the time you create it. If you update the state with the asynchronous function, and then you update it again with a synchronous function, the asynchronous function won’t see the update. This is because the counter variable that was updated is not the same variable the asynchronous function sees.

On the other hand, if you use the functional form, the counter will increase 2 times and you’ll get a 2 as a result. The functional form doesn’t capture a specific value for the state but gets the previous value when it’s time to update the state.

The same thing happens if you call the setState multiple times inside the same method (see at the end of the chapter in the previous link). The functional form will update the state multiple times but the regular form only once. See also a relevant StackOverflow question.

Check a CodeSanbox that shows the difference between the functional and the regular setState.

The setState function from useState doesn’t change during renders

We can validate this with the following quote that comes from the Hooks reference in the React docs:

React guarantees that setState function identity is stable and won’t change on re-renders. This is why it’s safe to omit from the useEffect or useCallback dependency list.

The same is true for the dispatch function from the useReducer hook.

In addition to the dependency list part, this also means that you don’t have to wrap this function in a useCallback hook when you pass it down from a parent to a child component (to prevent the child from re-rendering if the parent component renders).

In other words, you don’t have to worry that the identity of the function will change between renders, as it changes when you pass to a child component a function you create inside the render method of the parent component.

Other things to read

Popular

Previous/Next