7 things you may not know about useState

thoughtspile

Vladimir Klepov

Posted on September 29, 2021

7 things you may not know about useState

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));
}, []);
Enter fullscreen mode Exit fullscreen mode

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);
  }
};
Enter fullscreen mode Exit fullscreen mode

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 });
};
Enter fullscreen mode Exit fullscreen mode

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), []);
Enter fullscreen mode Exit fullscreen mode

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
}));
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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), []);
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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 throw if (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 for useCallback.
  • 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!

💖 💪 🙅 🚩
thoughtspile
Vladimir Klepov

Posted on September 29, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related