React State That Will Not Re-render, and When You Do Not Need useEffect
- Author
- Vishal Maurya
- Published on
- Reading time
- 5 min read
Overview
You call a state setter, the value changes in your console, and the screen stays the same. Or you add a useEffect to keep two pieces of state in sync and end up with extra renders and subtle bugs. Both problems come from how React decides when to update, and both have simple fixes.
1. The Mutation Trap
React decides whether state changed by comparing the new value with the old one using Object.is. If you mutate an object or array in place and pass the same reference to the setter, React sees no change and skips the update.
import { useState } from 'react';
type Todo = { id: number; text: string; done: boolean };
function Todos() {
const [todos, setTodos] = useState<Todo[]>([{ id: 1, text: 'Write post', done: false }]);
function toggleBroken(id: number) {
const todo = todos.find((t) => t.id === id);
if (todo) todo.done = !todo.done; // mutates existing object
setTodos(todos); // same reference, no re-render
}
function toggle(id: number) {
setTodos((prev) => prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
}
return (
<ul>
{todos.map((t) => (
<li key={t.id} onClick={() => toggle(t.id)}>
{t.done ? 'done: ' : ''}{t.text}
</li>
))}
</ul>
);
}
Treat state as immutable. Create new objects and arrays instead of changing existing ones.
2. Immutable Update Cheatsheet
- Add to an array:
[...items, newItem] - Remove:
items.filter((i) => i.id !== id) - Replace:
items.map((i) => (i.id === id ? { ...i, ...changes } : i)) - Update nested objects: spread each level that changed, for example
{ ...user, address: { ...user.address, city } }. - Sorting:
[...items].sort(...), sincesortandreversemutate the array. Newer JavaScript runtimes also offertoSortedandtoReversed; check browser support for your targets.
For deeply nested state, a helper library such as Immer can keep updates readable, or restructure state to be flatter.
3. Other Reasons the UI Does Not Update
- Stale reads after setting:
setCount(count + 1)followed by readingcountstill shows the old value in the same render. Use the functional formsetCount((c) => c + 1)for sequential updates. - Values in refs or variables: changing a
useRefvalue or a plain variable does not trigger a render. Use state for anything displayed. - Unstable keys: using array indexes as keys in a reorderable list can preserve the wrong component state.
- Setting identical primitive values: React bails out when the value is the same, which is expected.
4. Derive Values Instead of Syncing Them
A frequent anti-pattern is storing a computed value in state and updating it from an effect:
// Avoid
const [items, setItems] = useState<Item[]>([]);
const [visible, setVisible] = useState<Item[]>([]);
useEffect(() => {
setVisible(items.filter((i) => i.active));
}, [items]);
This renders once with stale visible, then again after the effect. Compute during render:
const visible = items.filter((i) => i.active);
If the computation is genuinely expensive, wrap it in useMemo with the correct dependencies, and measure before assuming you need it.
5. Reset State With a Key
Instead of an effect that resets a form when a prop changes, give the component a key so React creates a fresh instance:
<ProfileForm key={userId} userId={userId} />
For adjusting some state when a prop changes, prefer restructuring state, for example storing the selected id rather than the selected object, so the object is derived each render.
6. Handle Events in Handlers
If something happens because a user clicked a button, put the logic in the click handler, not in an effect that watches state. Effects are for synchronizing with external systems, such as subscriptions, timers, or the DOM, not for chaining state updates.
7. Data Fetching
A bare useEffect fetch needs cancellation and error handling to avoid race conditions: a slow earlier response can overwrite a newer one. If you do use an effect, guard it:
useEffect(() => {
let ignore = false;
fetch(`/api/search?q=${encodeURIComponent(query)}`)
.then((r) => r.json())
.then((data) => {
if (!ignore) setResults(data);
});
return () => {
ignore = true;
};
}, [query]);
Better, use a data-fetching layer such as TanStack Query or SWR on the client, or fetch in a Server Component or framework loader where available. These handle caching, deduplication, loading, and error states, and remove most manual effects.
8. A Quick Test for Effects
Ask: is this synchronizing with something outside React? If not, it is probably derived state, an event handler, or a reason to lift state up. Remove it and the component usually becomes shorter and easier to follow.
Conclusion
When state does not update, check for mutation and create new references. When effects pile up, derive values during render, handle events in handlers, reset with keys, and use a proper data-fetching tool.
If you want help cleaning up state management or effect-heavy components in a React codebase, I am happy to review it. Contact me.