Skip to content
SJ
All writing
10 min read

State That Doesn't Belong in React

Most React bugs are ownership bugs. Server data copied into useState, values derived and then stored, effects synchronising things that never needed synchronising.

ReactFrontendState ManagementPerformance

Almost every React bug I have chased for longer than an hour had the same root cause, and it was never rendering. It was a piece of state living somewhere it did not belong — copied into a component that did not own it, derived and then stored, or synchronised by an effect that should not have existed.

There are two kinds of state and they are not alike

UI state is yours. Is the dropdown open, which tab is active, what is typed in the input, which row is selected. It is created in the browser, it belongs to the component, and it dies with it. useState is exactly right for this and always has been.

Server state is a copy of something that lives elsewhere. It was already stale when it arrived. Someone else can change it without telling you. It needs caching, revalidation, deduplication across components, and a story for what happens when two tabs are open.

Putting the second kind into the first kind's container is the original sin, and everything that follows is downstream of it. A cache is not a variable, and useState is a variable.

The useEffect habit

Here is the pattern that appears in every codebase:

const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(true);

useEffect(() => {
  fetch(`/api/orders?customer=${customerId}`)
    .then((r) => r.json())
    .then(setOrders)
    .finally(() => setLoading(false));
}, [customerId]);

It works in the demo. What it does not handle, and what you will eventually write by hand, badly:

  • Race conditions. customerId changes twice quickly, two requests are in flight, and the slower one resolves last. You now display the wrong customer's orders, and nothing errored. This is the bug that reproduces once a week and never on your machine.
  • No cleanup. The component unmounts mid-request and you set state on nothing.
  • No deduplication. Three components need orders, so three requests go out.
  • No revalidation. The user leaves the tab for an hour and comes back to data from an hour ago, presented as current.
  • Two booleans that lie. loading and error as independent flags permit states that cannot happen and omit the one you need — refetching while showing old data.

Every one of those is solved, correctly, by any server-state library, or by fetching on the server before the component ever renders. The rule worth internalising: effects are for synchronising with systems outside React — a subscription, an event listener, a media element, an imperative third-party widget. Fetching data to put in state is not synchronising with an external system; it is caching, and caching deserves a real cache.

If you need one heuristic: an effect whose body ends in a setState is worth a second look. It is often either derived state or a fetch, and both have better answers.

Derived state is not state

The second most common instance of the same mistake:

const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);

useEffect(() => {
  setTotal(items.reduce((sum, i) => sum + i.price * i.qty, 0));
}, [items]);

total is not information. It is a function of items, and storing it creates a second source of truth that can disagree with the first — plus an extra render every time items change, because you set state during the render that followed the state you just set.

const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);

That is it. It cannot go stale, because there is nothing to keep in sync. Reach for useMemo only once you have measured that the computation is genuinely expensive — for arithmetic over a few hundred items it costs more than it saves.

The same applies to filtering and sorting a list, formatting a value, and computing whether a form is valid. If it can be calculated during render from things you already have, calculate it during render.

Lifting state further than it needs to go

The opposite failure. A modal's open/closed flag ends up in a global store because two sibling components needed it once. Now every consumer of that store re-renders when a dropdown opens somewhere unrelated, and the state outlives the component that owns it — so the modal remembers it was open after you navigate away and back.

State should live at the lowest common ancestor of the components that genuinely read it, and no higher. When lifting starts to hurt, the answer is usually composition rather than a store: pass the rendered children in as props so the parent that owns the state does not have to know what is inside them.

Context deserves the same care. It is a dependency-injection mechanism, not a state manager — every consumer re-renders when the value changes, so a context holding one frequently-updating object is a broadcast to the whole subtree. Split contexts by update frequency: the theme and the current user do not change; the form draft does.

When memo actually helps

memo, useMemo and useCallback are applied liberally and usually do nothing, because a re-render is not the expensive part. React re-rendering a component is a function call and a diff. What costs is a large tree, or real work inside the render.

They earn their place in three specific cases:

  • A genuinely expensive computation — parsing, a large sort, building a heavy data structure — that reruns on unrelated renders.
  • A large subtree that re-renders because a parent re-renders for unrelated reasons.
  • A value used in a dependency array or passed to a memoised child, where a new object identity each render defeats the memoisation downstream.

Outside those, memoisation adds a comparison, a closure and a cache entry to save nothing measurable. The order of operations is: fix the state ownership first, profile second, memoise third. Most of the time the profiler shows the problem was an effect chain or a context, and the memo would have hidden the symptom.

Keys are identity, not uniqueness

key is treated as a lint requirement, which is why key={index} is so common. But the key tells React which item this is across renders, and index keys claim that the item in position three is always the same item. Delete the first row and every subsequent item now has a key that used to belong to its neighbour — so the input state, focus and animation stay with the position rather than the data.

The inverse is a genuinely useful technique that is not widely known: changing a key deliberately to reset a component. A form that should clear when the selected record changes needs no effect and no reset function — give it key={recordId} and React unmounts the old one and mounts a fresh one with fresh state.

The short version

Separate state you own from data you borrowed, and stop keeping the borrowed kind in useState. Reserve effects for real external systems. Never store what you can calculate. Keep state at the lowest component that needs it, and split contexts by how often they change. Profile before memoising, because the fix is usually ownership. Then key lists by identity — and use the key deliberately when you want a reset.

Written by Saumya Jain

Full Stack Engineer working on headless commerce, NestJS microservices, and real-time systems. Currently open to remote work.