Technology Sep 20, 2026 · 15 min read

React Derived State: Why That useState Is Probably a Bug

You add a search box to a todo list. todos is state, filter is state, and — because the filtered list "depends on" both — you add a third state variable, visibleTodos, and a useEffect that recalculates it whenever todos or filter changes. It works. Then, months later, someone adds a bulk "complete a...

DE
DEV Community
by Parsa Jiravand
React Derived State: Why That useState Is Probably a Bug

You add a search box to a todo list. todos is state, filter is state, and — because the filtered list "depends on" both — you add a third state variable, visibleTodos, and a useEffect that recalculates it whenever todos or filter changes. It works. Then, months later, someone adds a bulk "complete all" button that updates todos directly, and the list on screen doesn't change for a beat. No error, no warning — just a stale list until the next keystroke nudges the Effect awake.

Nothing here is exotic. It's one of the most common bugs in React codebases, and it exists because a value that should have been computed got stored instead.

This is episode two of React Deep Dive, on what React itself decides rather than JavaScript wearing a React import. This one is about a decision every component makes constantly and mostly gets right by accident: which values belong in state, and which ones only look like they do.

This article is written against React 19.3 (verified against the React blog and GitHub releases, 19.3.0, published September 9, 2026) and the React Compiler at 1.0, stable since October 2025. Everything here assumes React 19-era function components and hooks.

What you'll learn

By the end of this article you'll be able to:

  • Recognize derived state — a value fully determined by props or other state — versus state that genuinely needs to exist
  • Explain, precisely, why syncing a derived value with useEffect + setState costs an extra render and can drift
  • Replace that pattern with a plain calculation during render, and know when to reach for useMemo instead
  • Handle the harder case — resetting or adjusting state when a prop changes — without an Effect
  • Tell the difference between "derived from what I already have" and "genuinely new information," which is the actual boundary

Who this is for

You've written function components with useState and useEffect, and you've shipped at least one bug where two pieces of state disagreed with each other. No prior knowledge of the React Compiler is assumed.

Table of contents

  • The problem: a list that lags behind its own data
  • The mental model: state is memory, render is a formula
  • Stage 1: the naive fix and why it still isn't right
  • Stage 2: delete the state, keep the value
  • Stage 3: when the calculation is actually expensive
  • Stage 4: the harder case — resetting state when a prop changes
  • Edge cases and gotchas
  • Best practices: the actual test
  • FAQ
  • Cheat sheet
  • Key takeaways

The problem: a list that lags behind its own data

Here's the todo list from the opening, written the way it tends to get written the first time:

function TodoList({ todos }) {
  const [filter, setFilter] = useState("all");
  const [visibleTodos, setVisibleTodos] = useState(todos);

  useEffect(() => {
    setVisibleTodos(
      filter === "all" ? todos : todos.filter((t) => t.done === (filter === "done"))
    );
  }, [todos, filter]);

  return (
    <>
      <FilterButtons value={filter} onChange={setFilter} />
      <ul>
        {visibleTodos.map((t) => <li key={t.id}>{t.text}</li>)}
      </ul>
    </>
  );
}

This runs. It even looks reasonable — visibleTodos "depends on" todos and filter, so it lives in an Effect that watches both. But walk through what actually happens on a single click of a filter button:

  1. setFilter("done") schedules a render. TodoList re-renders with the new filter but the old visibleTodos — React hasn't run your Effect yet, because Effects run after the DOM commits.
  2. The user briefly sees the wrong list (all todos, not just the done ones), for one paint.
  3. The Effect then runs, calls setVisibleTodos, and schedules a second render.
  4. React renders again, this time with the correct filtered list.

That's two full render passes and one commit doing visible work for a value you could have had correct on the first pass. And the bulk "complete all" bug from the intro is the same mechanism from the other direction: something mutates todos through a path that doesn't also re-run this specific Effect's mental model correctly, or a later render reads visibleTodos before the Effect catches up, and the two state variables disagree.

None of this is a React bug. React is doing exactly what you asked: keep two separate pieces of memory, and use an Effect to keep the second one following the first. The bug is that visibleTodos was never independent information — it was a formula wearing state's clothes.

The mental model: state is memory, render is a formula

Split every value your component touches into two categories:

  • State is memory. It's the only thing React can't reconstruct on its own — user input, a value from a request, anything genuinely new that arrived from outside this render.
  • A derived value is anything you could recompute, right now, from state and props you already have. It isn't information; it's a formula over information.

The mental model: if you can write const x = f(props, otherState) and get the right answer every time, x was never state — it's a calculation, and calculations belong in the render body, not in a useState/useEffect pair. An Effect exists to synchronize your component with something outside React — the DOM, a subscription, a network request, document.title. Using one to copy one piece of React state into another piece of React state is React talking to itself through a detour, and the detour is where the extra render and the drift both come from.

This reframes useEffect itself: it isn't "the place derived stuff goes," it's "the place synchronization with the outside world goes." A value computed from props and state was never outside anything.

Stage 1: the naive fix and why it still isn't right

A common first correction is to memoize inside the Effect, or to add a guard so it "only runs when needed":

useEffect(() => {
  const next = filter === "all" ? todos : todos.filter((t) => t.done === (filter === "done"));
  if (next !== visibleTodos) setVisibleTodos(next);
}, [todos, filter]);

Key concept: this treats the symptom (an extra render) without touching the cause (a second copy of information that has to be kept honest). The stale-paint flash from step 2 above is still there — the Effect still runs after the commit, not before it — and you've added a comparison that has to be maintained forever. The state was the mistake; no amount of guarding the Effect fixes that.

Stage 2: delete the state, keep the value

The actual fix removes code:

function TodoList({ todos }) {
  const [filter, setFilter] = useState("all");

  const visibleTodos =
    filter === "all" ? todos : todos.filter((t) => t.done === (filter === "done"));

  return (
    <>
      <FilterButtons value={filter} onChange={setFilter} />
      <ul>
        {visibleTodos.map((t) => <li key={t.id}>{t.text}</li>)}
      </ul>
    </>
  );
}

No visibleTodos state, no Effect, no second render, no drift — because there is only one piece of information (todos and filter) and one formula over it. Click a filter button now, and TodoList renders exactly once, with the correct list, because the correct list was never anything but todos and filter combined.

Key concept: a value that's recomputed on every render is not "wasted work" by default — rendering is supposed to be cheap and pure. Reach for a second render only when React genuinely needs one; a plain const inside the component body isn't a render, it's a step within the one you're already doing.

Stage 3: when the calculation is actually expensive

Sometimes the formula really is costly — sorting or filtering thousands of rows, for instance — and recomputing it on every render (including ones triggered by something unrelated, like typing in an unrelated text field on the same component) is wasteful. That's what useMemo is for:

const visibleTodos = useMemo(
  () => (filter === "all" ? todos : todos.filter((t) => t.done === (filter === "done"))),
  [todos, filter]
);

useMemo still computes the value during render, synchronously, before anything paints — it just skips redoing the work if todos and filter are unchanged since last time. It is a performance optimization, not a place to put a second copy of the answer, and it produces no second commit the way the Effect version did.

Episode one of this series (React Compiler 1.0: What useMemo You Can Delete) covers the other side of this: with the Compiler enabled, this specific useMemo is usually one you no longer have to write by hand — the compiler memoizes it for you. What it will never do is turn a useEffect-plus-setState pair back into a derived value; that rewrite is a design decision, not something a compiler can safely infer, because it can't know your Effect wasn't also doing something with genuine side effects on the outside world.

Stage 4: the harder case — resetting state when a prop changes

Sometimes what looks like "syncing derived state" is really about a prop change that should reset unrelated state — a <ProfilePanel userId> where switching users should clear a draft comment, for instance. The Effect-shaped instinct is the same trap:

// Don't do this
function ProfilePanel({ userId }) {
  const [comment, setComment] = useState("");
  useEffect(() => {
    setComment("");
  }, [userId]);
  // ...
}

This has the identical problem as Stage 1: one render with the stale comment, then an Effect-triggered second render that clears it. The fix here isn't a calculation — comment genuinely is state, freely typed by the user, not derivable from anything — the fix is telling React this is a different instance of the component, which is a subject the first episode of this series covered in depth: giving the component a key.

<ProfilePanel userId={userId} key={userId} />

When userId changes, React doesn't update the existing ProfilePanel instance — it discards it and mounts a fresh one, with comment back at its initial value, in the same render, no Effect involved. See React Re-render vs Remount: What Actually Triggers Each for exactly how React decides between updating an instance and replacing it. The two bugs in this article and that one are the same shape from two directions: this one is about values you shouldn't have kept as separate memory at all; that one is about memory you correctly kept, but attached to the wrong lifetime.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

Edge cases and gotchas

  • Adjusting only some state when a prop changes. If a key reset would throw away too much (say, only one field should clear, the rest should survive), React's own guidance is to compute that one field during render by comparing the current prop against a previous-value stored in state — a narrow, deliberate escape hatch, not a general pattern, and one that reads oddly enough that it deserves a comment explaining why it isn't an Effect.
  • Fetched data is not derived state. todos arriving from an API is genuinely new information your component couldn't calculate on its own — that's real state (or better, a request-lifecycle tool that isn't plain useState). The rule in this article is about values computed from data you already hold, not the data itself.
  • Derived values that also need to survive a remount. If a formula's result should persist across a key change on purpose, it can't be "just a calculation" anymore — you've described state, and that's fine; just be honest that it is one.
  • useMemo is not a correctness tool. It changes when the calculation reruns for performance, never what the calculation returns. Never rely on useMemo to skip work that has an externally visible side effect — that's what useEffect is for, and the two aren't interchangeable.
  • Context values built from derived state. A useMemo-wrapped object passed through Context.Provider is a very common and correct use of memoization — it stops every consumer from re-rendering just because the provider re-rendered with a fresh object literal. That's a real performance concern this pattern solves; it's a different subject from this article's bug, not evidence that all derived values need memoizing.

Best practices: the actual test

Ask one question, in this order, every time a new value shows up in a component: could I compute this, right now, from props and state I already have?

  • Yes, and it's cheap → a plain const in the render body. No hook.
  • Yes, but it's measurably expensiveuseMemo, still computed during render, still zero extra commits.
  • No, this value is genuinely new information from outside React (user input, a fetch response, a timer tick) → it's real state, and useEffect is the right tool if it also needs to synchronize with something outside React.
  • A prop change should reset an entire component's state → a key, not an Effect that calls several setStates in a row.

If you ever catch yourself writing a useEffect whose only job is calling setState with a value derived purely from props and other state already in scope, that Effect is the bug, not the fix.

FAQ

Is every useState that depends on props automatically derived state?

Not automatically — the test is whether the value is fully determined by props and other state, not whether it merely reads them. A text input's value reads a defaultValue prop once, then holds independent user edits; that's real state that happened to be seeded from a prop, not a formula recomputed from it every render.

Does using useMemo mean a value isn't derived state anymore?

No — useMemo is still deriving the value during render from the same inputs; it only caches the result between renders with unchanged inputs. A useMemo'd value and a plain const are the same kind of thing (a formula), differing only in whether the formula's cost justifies caching it.

Why does the Effect-based version actually visibly flash the wrong content?

Because Effects run after the browser has painted the render's output, not before it. The render with stale derived state is not skipped or invisible — it's a real commit the user can see, however briefly, before the Effect's setState triggers the corrected one.

Does the React Compiler make this whole article unnecessary?

No. The Compiler (stable at 1.0) automates memoizing values you already compute during render — it will not rewrite a useEffect-plus-setState pair into a derived calculation for you, because it can't safely know your Effect isn't also doing something with a real side effect. Removing the state is still a decision you make.

What's the actual bug, in one sentence, if I never fix this?

Two variables that are supposed to always agree occasionally won't, because one is memory and the other is a stale copy of a formula over that memory — and every real production codebase eventually hits the sequence of updates that makes them disagree.

🧠 Test yourself

Think it clicked? Take the 7-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

Cheat sheet

Situation What it is What to write
Value fully computable from current props/state Derived value const x = f(props, state) — no hook
Same, but the computation is measurably slow Derived value, cached useMemo(() => f(...), [deps])
Genuinely new info from outside React (input, fetch, timer) State useState, updated by the event/callback that produced it
A prop change should reset a whole component's state Identity change <Child key={propValue} />
A prop change should reset only one field, rest must survive Narrow adjustment Compare a stored previous value during render — last resort, comment it
Object/array passed through Context that would otherwise churn Derived value, cached for consumers useMemo around the Provider's value
// The one-line test, every time you reach for useState:
// "Could I calculate this from props/state I already have?"
// Yes  -> const, or useMemo if it's expensive. No Effect.
// No   -> it's real state. useEffect only if it must sync with
//         something outside React (DOM, subscription, network).

Key takeaways

  • If a value can be computed from props and state you already have, it isn't state — it's a formula, and it belongs in the render body.
  • A useEffect that calls setState with a derived value costs a real extra render (Effects run after the commit) and creates two copies of one fact, which can drift.
  • useMemo caches a derived calculation for performance; it never changes what the calculation returns and never replaces the decision to remove unnecessary state in the first place.
  • When a prop change should reset a component's state entirely, use key, not an Effect — it's a single render, not two.
  • The React Compiler automates memoizing derived values; it does not and cannot decide for you that a state variable shouldn't have existed.

Back to that todo list

The fix for the opening bug was never a smarter Effect — it was noticing that visibleTodos had no information in it that todos and filter didn't already have. Delete the state, keep the formula, and the "complete all" bug and the one-paint flash both disappear, because there was never a second fact to get out of sync in the first place.

Next time you write useEffect(() => setSomething(...), [dep]), try the one-line test from this article before you commit it: could something just be a const? If yes, you've probably found a bug before your users did.

What's the strangest derived-state bug you've shipped — and how long did it take to notice? Drop it in the comments.

🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

DE
Source

This article was originally published by DEV Community and written by Parsa Jiravand.

Read original article on DEV Community
Back to Discover

Reading List