In this article, I will draw attention to some problems and edge cases that may occur while using the useState
hook. This hook stores a value that is used when rendering components. It is one of the most commonly used hooks, and most of the time you can use it with no problems and it will behave as expected. But there are some exceptions, which I will cover in this article.
The topics that I will address are:
- When will setState cause a re-render?
- React.memo and changing state
- setState changes are not immediately visible
- Batching
- Lazy initialization
- When to use setState with a callback?
- Using useState to store element reference
When will setState cause a re-render?
If you are familiar with class components, you might think that the hook equivalent of this.setState
always causes a re-render. The hook method uses the Object.is on every state change (call of setState method) and compares the previous value with the newer one. That being said, if we use the useState
with primitive values (number, string, boolean, undefined, symbol) it will not cause a re-render if the value didn't change:
Object.is(2, 2); // true
Object.is("value", "value"); // true
Object.is(true, true); // true
Object.is(undefined, undefined); // true
Object.is(null, null); // true
If we use the useState
with objects
or functions
, a re-render would happen only when the reference changes:
Object.is({}, {}); // false
Object.is([], []); // false
Object.is(() => console.log(""), () => console.log("")); // false
const foo = {a: 1};
const clone = foo;
Object.is(foo, clone); // true
Object.is(foo, {a: 1}); // false
This is one of the reasons why we should never directly mutate state because React will not detect the change and cause a re-render. It is also important when dealing with objects/arrays
to not only set the new values but also to copy the previous ones (if you used React class components this behavior is different since React would have merged new and previous state values, so you would only need to set changes). So, if we have a complex state with nested objects:
// complex state with nested objects
const [complexState, setComplexState] = useState({
foo: 'bar',
bar: 'foo',
errors: {
foo: 'required',
bar: 'required'
}
})
and want to change the errors.foo
value we would do it like this:
setComplexState({
...complexState,
errors: {
...complexState.errors, // we need to copy deeply nested object
foo: 'new value'
}
})
Object.is
used to compare states when setState is called. Primitive values are compared by value, and the complex ones by reference
React.memo and changing state
React.memo
will not prevent a re-render of the component where we use the useState
hook. React.memo
is strictly used to bailout of re-rendering child components when their parent re-renders. I intentionally didn't use the phrase: "when props change", since by default child components will re-render even if props stayed the same, and their parent rendered (only memoized components do a shallow comparison of props).
The mentioned behavior differentiates from its class component equivalent: shouldComponentUpdate
, which is triggered when both state
or props
change, and can bail out of rendering even when the state changes.
React.memo doesn't prevent re-rendering when state changes when using
useState
setState changes are not immediately visible
When we call setState
, state change won’t be visible straight away. React will queue the update and sometimes even batch multiple updates so our components don't render too many times (more on that in the next section).
const [state, setState] = useState(0);
useEffect(() => {
setState(1);
console.log(state); // state is still 0
}, []);
State changes are visible in the next render
Batching
It is quite common that we use multiple useState
hooks, and call their set methods inside the same callback/useEffect call. React will by default batch those updates togheter so that our component will render only once, and not for each setState
call:
export default function Component() {
const [state1, setState1] = useState(0);
const [state2, setState2] = useState(0);
useEffect(() => {
console.log({ state1, state2 });
});
const onClick = () => {
setState1(state1 + 1);
setState2(state2 + 1);
};
return <button onClick={onClick}>Click Me</button>;
}
when we click on the button, in the next render, we will see updated state1
and state2
. There will never be a situation in which state1 !== state2
.
However, there are some cases in which React will not batch updates:
- if we call
setState
methods inside an async function - if we call
setState
inside asetTimeout
/setInterval
This is usually not a big performance issue, since React renders are pretty fast, but we could end up in an intermediate state that we didn't expect, and it could cause our application to stop working.
If we alter the previous example, into changing the state after a timeout:
export default function Component() {
const [state1, setState1] = useState(0);
const [state2, setState2] = useState(0);
useEffect(() => {
console.log({ state1, state2 });
});
const onClick = () => {
// state is changed inside a setTimeout now
setTimeout(() => {
setState1(state1 + 1);
setState2(state2 + 1);
}, 0)
};
return <button onClick={onClick}>Click Me</button>;
}
By clicking on the set button, our component would render twice: the first render would update state1
, and the second one would update state2
.
There is an unstable API provided by React which can batch updates even inside async/setTimeout
calls: React.unstable_batchedupdates
. It is used internally by React
when batching changes in event handlers or during a sync flow.
I personally prefer to use the useReducer
hook when dealing with interconnected states. It allows me to write exact state changes (creating a state machine of sorts) with ease and helps me eliminate the possibility of rendering our component in an intermediate state. An example of this is a simple useFetch
hook, that clearly defines all possible states:
function useFetch(initialState = {isLoading: true}) {
// defined our state machine, so we are certain only these states
// are possible and all connected states are updated in single render
const reducer = (state, action) => {
switch (action.type) {
case 'request':
return { isLoading: true };
case 'response': {
return { isLoading: false, data: action.data };
}
case 'error': {
return { isLoading: false, error: action.error };
}
default:
return state;
}
};
const [fetchDataState, dispatch] = useReducer(reducer, initialState);
const fetchData = async (fetchOptions, abortSignal) => {
try {
dispatch({ type: 'request' });
const data = await fetcher.fetchData(fetchOptions, abortSignal);
// this will set both loading and fetched data for next render
dispatch({ type: 'response', data: data });
} catch (e) {
dispatch({ type: 'error', error: e });
}
};
return { ...fetchDataState, fetchData };
}
The state changes will not be batched when using inside async or setTimeout/setInterval flow
Lazy initialization
When we want to initialize state with some potentially expensive operation, which we don't want triggering on every render (for example filtering of a big list), we can put a custom function when initializing useState
. That function will only be called on the first render, and its results will be set as the initial value of the useState
:
const [state, setState] = useState(() => {
props.initialValue.filter(...) // expensive operation
})
You just need to be careful that this is only called on the first render. If I have props, for example, that are used to initialize state, I like to prefix the prop name with initial
or default
to signal other devs that this value will not be synced if it changes.
useState
supports lazy initialization for expensive operations
When to use setState with a callback?
setState
has two call signatures:
- you can call it with a new value
- you can call it with a callback that receives the current value as an argument and returns the new value
The callback signature is beneficial when calling setState
inside a useCallback
hook so that we don't break memoization.
If we have a simple component that uses useState
and useCallback
hooks with a memoized child component, and write it using the simple setState
call signature:
const [state, setState] = useState(0);
const onValueChanged = useCallback(() => {
setState(state + 1);
}, [state, setState]);
return <div>
{state}
<MemoizedChild onValueChanged={onValueChanged } />
</div>
we will ruin the optimization of our MemoizedChild
. Since onValueChanged
will change on every state
change, its reference will change as well, which will result in different props being sent to our child component (even if it doesn't use state
in its props). This can be fixed easily by using the callback signature:
const [state, setState] = useState(0);
const onValueChanged = useCallback(() => {
setState(prevState => prevState + 1); // change to callback signature
}, [setState]); // remove state from dependencies since callback will provide current value
return <div>
{state}
<MemoizedChild onValueChanged={onValueChanged } />
</div>
This will work because the setState
reference will be constant throughout the whole lifecycle of our component. With this adjustment, the MemoizedChild
component will not render when the state changes.
Use the callback signature of
setState
when using withuseCallback
Using useState to store element reference
When you need to reference a React element you can usually use the useRef
hook. However, what if you want to do something with the element when it is first rendered (i.e., attach an event listener, calculate dimensions, ...) or if you want to use the reference as a dependency for useEffect/useCallback? In these cases useRef
will not trigger a re-render of our component, so we would need to combine it with the useEffect
. You could use useState
to get the object reference, and it would force a re-render after the element is rendered, so you could access it:
export default function Component() {
const [buttonRef, setButtonRef] = useState();
useEffect(() => {
console.log({ buttonRef });
});
return <button ref={setButtonRef}>Click Me</button>;
}
This way you would save the element reference in the state as soon as the element is rendered, and could safely use it without manually syncing it.
Conclusion
In this article, I covered some advanced useState
cases. Hope you enjoyed it and found it useful :)
If you are interested in learning more about this topic, you can check these links: