Two useState Features Worth Knowing

Most React developers learn the basic useState pattern early on: pass an initial value, get back the current state and a dispatch function.

But both useState itself and the dispatch function it returns support an alternative call signature: you can pass a function instead of a value. For useState, the function returns the initial state. For the dispatch function, the function receives the previous state and returns the new one. The behavior is identical to the plain value form, but the differences matter in specific scenarios.

Lazy Initialization for Expensive Setup

When your component re-renders, the entire function body runs again—including any code that computes an initial state value. That's normally fine; JavaScript engines are fast and optimize such work well. But if your initial state requires significant computation or an IO operation like reading localStorage, running that on every render is wasteful.

The key point is that React only needs the initial state on the first render. Lazy initialization lets you defer that work:

const getInitialState = () => Number(window.localStorage.getItem('count'))
const [count, setCount] = React.useState(getInitialState)

Passing a function to useState is cheap—creating the function costs little regardless of what it does. React invokes it only when the initial value is actually needed, which is during the component's initial render. This is a performance optimization, not a feature you'll reach for constantly; it's most useful when the initialization work is genuinely expensive.

Function Updates Avoid Stale State

The more subtle issue arises when you need to update state asynchronously. Consider an event handler that performs an async operation before calling the dispatch function:

function DelayedCounter() {
	const [count, setCount] = React.useState(0)
	const increment = async () => {
		await doSomethingAsync()
		setCount(count + 1)
	}
	return <button onClick={increment}>{count}</button>
}

If you trigger this handler multiple times quickly, you might expect the count to increment each time. In practice, it only increments once. The state is being updated three times, but every update computes from the same stale value.

The reason is closure behavior. The increment function handed to React via onClick captures the value of count from the render in which it was created. Until React re-renders, every invocation of that same function sees count as 0. Waiting for the re-render between clicks works because a fresh function with a fresh count is then created—but rapid clicks all use the old one.

Function updates solve this directly. Instead of reading count yourself, you give the dispatch function a callback that receives the most up-to-date state:

function DelayedCounter() {
	const [count, setCount] = React.useState(0)
	const increment = async () => {
		await doSomethingAsync()
		setCount((previousCount) => previousCount + 1)
	}
	return <button onClick={increment}>{count}</button>
}

Now each update computes from the latest state value rather than a captured one, so rapid clicks each produce the correct increment. The dispatch function's callback is guaranteed the current state regardless of what's in your closure.

A related rule of thumb: whenever new state depends on previous state, use the function-update form. This same problem doesn't occur with useReducer, because reducers always receive the most recent state as their first argument. The one exception is when your state update depends on props or external values—those may still be stale, in which case a useRef is the standard workaround.

When to Use Each

  • Lazy initialization—a tool for performance. Use it when computing the initial state is costly, such as heavy calculations or storage reads. It's rarely needed but valuable to know.
  • Function updates—a correctness tool. Use them any time you compute new state from previous state, especially inside async handlers or any code that might run before a re-render.