useState has some behaviours that matter for performance. Knowing them lets you avoid unnecessary work and unnecessary anti-patterns.
Don’t guard state updates
You don’t need to check whether the value has changed before calling a setter:
// Unnecessary: useState already does this check
if (value !== newValue) {
setValue(newValue);
}
// Just do this
setValue(newValue);
React’s useState implementation already uses Object.is to compare the new value with the current one. If they’re the same, it bails out. Your manual check adds nothing – though it does no harm either.
There’s a catch, though: even when the value hasn’t changed, React may still call your component function once before bailing out. This happens because React can only do an “eager” comparison when it’s certain the update queue is empty. If there’s any ambiguity, it speculatively renders the component, checks whether the output differs, and then bails out before reconciling children or committing to the DOM.
In practice this means setState(sameValue) is not a complete no-op. Your component function body still executes – including any expensive inline logic that isn’t wrapped in useMemo. You’ll see the component briefly appear in the profiler even though nothing visibly changed. The children won’t re-render and the DOM won’t update, but the function call still happens.
Use functional updates to stabilise callbacks
This is a common pattern that defeats its own purpose:
const Modal = () => {
const [isOpen, setIsOpen] = useState(false);
const toggle = useCallback(
() => setIsOpen(!isOpen),
[isOpen]
);
return <ModalContent onClick={toggle} show={isOpen} />;
};
The intention is to cache toggle so that ModalContent (assuming it’s memoised) doesn’t re-render unnecessarily. But toggle depends on isOpen, so it’s recreated every time isOpen changes – which is every time it’s clicked. The memoisation is doing nothing useful.
The fix is to use a functional update. The setter provides the current value as an argument:
const Modal = () => {
const [isOpen, setIsOpen] = useState(false);
const toggle = useCallback(
() => setIsOpen(prev => !prev),
[]
);
return <ModalContent onClick={toggle} show={isOpen} />;
};
Now toggle has no dependencies, so it’s created once and never changes. The functional update reads the current state at call time, so it’s always correct.
Lazy initialisation
If the initial state requires an expensive computation, pass a function to useState:
// Bad: parseData runs on every render (result is ignored after the first)
const [data, setData] = useState(parseData(rawData));
// Good: parseData runs only on mount
const [data, setData] = useState(() => parseData(rawData));
Without the function wrapper, parseData(rawData) runs every render – React just throws away the result after the first. With the function wrapper, React only calls it during the initial mount.