Doing code reviews for our hook-based project, I often see fellow developers not aware of some awesome features (and nasty pitfalls) useState
offers. Since it’s one of my favourite hooks, I decided to help spread a word. Don’t expect any huge revelations, but here’re the 7 facts about useState
that are essential for anyone working with hooks.
Update handle has constant reference
To get the obvious out of the way: the update handle (second array item) is the same function on every render. You don’t need to include it in array dependencies, no matter what eslint-plugin-react-hooks has to say about this:
const [count, setCount] = useState(0);
const onChange = useCallback((e) => {
// setCount never changes, onChange doesn't have to either
setCount(Number(e.target.value));
}, []);
Setting state to the same value does nothing
useState
is pure by default. Calling the update handle with a value that’s equal (by reference) to the current value does nothing — no DOM updates, no wasted renders, nothing. Doing this yourself is useless:
const [isOpen, setOpen] = useState(props.initOpen);
const onClick = () => {
// useState already does this for us
if (!isOpen) {
setOpen(true);
}
};
This doesn’t work with shallow-equal objects, though:
const [{ isOpen }, setState] = useState({ isOpen: true });
const onClick = () => {
// always triggers an update, since object reference is new
setState({ isOpen: false });
};
State update handle returns undefined
This means setState can be returned from effect arrows without triggering Warning: An effect function must not return anything besides a function, which is used for clean-up. These code snippets work the same:
useLayoutEffect(() => {
setOpen(true);
}, []);
useLayoutEffect(() => setOpen(true), []);
useState is useReducer
In fact, useState
is implemented in React code like a useReducer
, just with a pre-defined reducer, at least as of 17.0 — ooh yes I actually did check react source. If anyone claims useReducer
has a hard technical advantage over useState
(reference identity, transaction safety, no-op updates, etc) — call him a liar.
You can initialize state with a callback
If creating a new state-initializer object on every render just to throw away is concerning to you, feel free to use the initializer function:
const [style, setStyle] = useState(() => ({
transform: props.isOpen ? null : 'translateX(-100%)',
opacity: 0
}));
You can access props (or anything from the scope, really) in the initializer. Frankly, it looks like over-optimization to me — you’re about to create a bunch of vDOM, why worry about one object? This may help with heavy initialization logic, but I have yet to see such case.
On a side note, if you want to put a function in your state (it’s not forbidden, is it?), you have to wrap it in an extra function to bypass the lazy initializer logic: useState(() => () => console.log('gotcha!'))
You can update state with a callback
Callbacks can also be used for updating state — like a mini-reducer, sans the action. This is useful since the current state value in your closure may not be the value if you’ve updated the state since rendering / memoizing. Better seen by example:
const [clicks, setClicks] = useState(0);
const onMouseDown = () => {
// this won't work, since clicks does not change while we're here
setClicks(clicks + 1);
setClicks(clicks + 1);
};
const onMouseUp = () => {
// this will
setClicks(clicks + 1);
// see, we read current clicks here
setClicks(clicks => clicks + 1);
};
Creating constant-reference callbacks is more practical:
const [isDown, setIsDown] = useState(false);
// bad, updating on every isDown change
const onClick = useCallback(() => setIsDown(!isDown), [isDown]);
// nice, never changes!
const onClick = useCallback(() => setIsDown(v => !v), []);
One state update = one render in async code
React has a feature called batching, that forces multiple setState calls to cause one render, but is’s not always on. Consider the following code:
console.log('render');
const [clicks, setClicks] = useState(0);
const [isDown, setIsDown] = useState(false);
const onClick = () => {
setClicks(clicks + 1);
setIsDown(!isDown);
};
When you call onClick
, the number of times you render
depends on how, exactly, onClick
is called (see sandbox):
-
<button onClick={onClick}>
is batched as a React event handler -
useEffect(onClick, [])
is batched, too -
setTimeout(onClick, 100)
is not batched and causes an extra render -
el.addEventListener('click', onClick)
is not batched
This should change in React 18, and in the meantime you can use, ahem, unstable_batchedUpdates
to force batching.
To recap (as of v17.0):
-
setState
in[state, setState] = useState()
is the same function on every render -
setState(currentValue)
does nothing, you can throwif (value !== currentValue)
away -
useEffect(() => setState(true))
does not break the effect cleanup function -
useState
is implemented as a pre-defined reducer in react code - State initializer can be a calback:
useState(() => initialValue)
- State update callback gets current state as an argument:
setState(v => !v)
. Useful foruseCallback
. - React batches multiple setState calls in React event listeners and effects, but not in DOM listeners or async code.
Hope you’ve learnt something useful today!
Top comments (0)