React Hooks & Rendering
React Hooks & Rendering

Untangling a component with too many effects

Full · ~30 min
After this unit you can
  • Refactor a derived-state effect into a value computed during render, like const count = text.length
  • Spot why a no-deps prop-to-state sync clobbers typing, and reset local state with a key instead
  • Fix a listener effect that piles up: named handler, matching removeEventListener, and [] deps
  • Explain why StrictMode (React 18+) runs effects twice in development and answer it with a cleanup, not a guard
  • Spot why deleting a dependency the effect reads is the wrong fix for an effect that re-runs

Read

Simple
1 / 2

Read a crowded component by naming each effect

Flag

When you open a component with four useEffects, don't start editing. Give each one a one-sentence name — "what outside thing does this synchronize with?" Almost every effect you'll meet is one of five kinds:

KindExampleVerdict
Subscribe to something externaladdEventListener, a socket, a store✅ a real effect — needs cleanup
Imperative DOM call.focus(), measuring, scroll position✅ a real effect
Fetch / write to a serverload on mount, autosave⚠️ effect, but a data library usually owns it
Compute state from other state/propssetCount(text.length)❌ not an effect — derive during render
Copy a prop into statesetText(props.title)❌ not an effect — usually a key or lifting fixes it

If you can't finish the sentence "this effect synchronizes with …", the effect is probably the bug. The two ❌ rows are the ones that produce a component full of effects fighting each other.

Medium

Auditing a real four-effect component

Flag

Here's the shape of a description editor you'll meet in the wild. Four effects, three of them wrong.

function DescriptionEditor({ description, onCommit }: Props) {
  const [text, setText] = useState(description);
  const [count, setCount] = useState(0);
  const [width, setWidth] = useState(window.innerWidth);
  const [saving, setSaving] = useState(false);

  useEffect(() => { setCount(text.length); }, []);                 // 1
  useEffect(() => { setText(description); });                      // 2
  useEffect(() => {                                                // 3
    window.addEventListener("resize", () => setWidth(window.innerWidth));
  });
  useEffect(() => {                                                // 4
    const id = setTimeout(() => { setSaving(true); onCommit(text); }, 1000);
    return () => clearTimeout(id);
  }, [text, onCommit]);
}

1 — derived state with the wrong deps. count should never have been state. const count = text.length deletes the effect and the stale-[] bug at once. Reach for this fix first: it removes a whole class of dependency mistakes.

2 — prop→state sync with no dependency array. No deps means "after every render," so this clobbers whatever the user typed on the very next render. If the field must reset when a different record loads, don't sync in an effect — remount it: <DescriptionEditor key={record.id} … />. If the parent needs the value, lift the state instead of mirroring it.

3 — a subscription with no cleanup and no deps. Every render adds another listener, and nothing ever removes them: one resize event soon runs the handler dozens of times, and the listeners keep firing after unmount. A subscription effect is the real deal — but it must return a cleanup and declare []:

useEffect(() => {
  const onResize = () => setWidth(window.innerWidth);
  window.addEventListener("resize", onResize);
  return () => window.removeEventListener("resize", onResize);
}, []);

Note the named handler: removeEventListener only removes a listener it can identify by reference, so an inline arrow can never be removed.

4 — legitimately an effect, but it fights the component. Because it re-runs on every text change, it is a debounce (nice), but onCommit in the dependency list means an inline parent callback re-creates the timer on every parent render. Either memoize onCommit in the parent (useCallback), or read it through an Effect Event (useEffectEvent, React 19.2+) and keep the deps to [text].

Result: four effects become one subscription effect plus one save effect — with count derived and the prop-sync deleted.

Complex

Dependency arrays, StrictMode, and extracting the effect

Flag

The three dependency arrays mean three different things

useEffect(fn);          // after EVERY render — almost always a mistake
useEffect(fn, []);      // on mount (cleanup on unmount); StrictMode adds a dev-only extra cycle
useEffect(fn, [a, b]);  // when a or b changes (by Object.is)

[a, b] compares by identity, so an object/array/function literal created during render is a new dependency every time: the effect re-runs after every render (and can loop forever if it also sets state to a new value). That's the same referential-identity problem as React.memo — see the memoization unit. Fixes, in order of preference: depend on a primitive (user.id, not user), move the object creation inside the effect, or useCallback/useMemo the dependency at its source.

Never "fix" a loop by deleting a dependency the effect actually reads. That doesn't remove the bug, it hides it behind a stale closure — the effect keeps using values from the render that created it. If a value should be read but not reacted to, that's what an Effect Event is for (useEffectEvent, stable since React 19.2):

const save = useEffectEvent((value) => onCommit(value)); // always the latest onCommit
useEffect(() => {
  const id = setTimeout(() => save(text), 1000);
  return () => clearTimeout(id);
}, [text]); // ✅ re-runs for new text, not for a new onCommit identity

Only use it for the part that genuinely shouldn't trigger the effect — react.dev warns against wrapping code in an Effect Event just to silence the dependency linter. Before 19.2, the same job took a ref refreshed in an effect (useEffect(() => { latest.current = onCommit; })), never assigned in the render body.

StrictMode double-invokes effects on purpose

In development, React 18+ StrictMode simulates an unmount and remount right after each component mounts: setup, cleanup, setup again, with state kept. An effect without a correct cleanup shows up immediately as a doubled subscription, a doubled fetch, or a doubled interval. That noise is the point: an effect you can safely run twice is an effect you wrote correctly. Don't add a didRun ref to silence it — write the cleanup.

Extract the effect once it has a name

When an effect plus its state has a name you can say out loud, it's a custom hook:

function useWindowWidth(): number {
  const [width, setWidth] = useState(() => window.innerWidth);
  useEffect(() => {
    const onResize = () => setWidth(window.innerWidth);
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);
  return width;
}

The four-effect component becomes const width = useWindowWidth() — and the hook is testable on its own. That's the endgame of an effect audit: derive what can be derived, delete what shouldn't exist, and extract what remains until each component body reads like a description of the UI rather than a list of side effects.

Order of attack, every time: derive → delete → dedupe deps → extract.

Quiz

Which of these does NOT belong in a useEffect?

Flag

What does useEffect(() => { setText(description); }) — no dependency array — do?

Flag

A form's local draft state must reset when the parent selects a different record. What's the idiomatic fix?

Flag

useEffect(() => { window.addEventListener("resize", () => setWidth(window.innerWidth)); }) — what goes wrong?

Flag

In development (React 18+, StrictMode) your effect runs setup, cleanup, then setup again on mount. What's the right response?

Flag

An effect re-runs on every render because onCommit (an inline arrow from the parent) is in its dependency array. What's the WRONG fix?

Flag

Fill in the blank

Delete the derived-state effect: compute the character count during render.

Flag
const [text, setText] = useState("");
const count = .;

Subscribe to window resize correctly: a named handler, a cleanup, and deps that make it run once.

Flag
useEffect(() => {
  const onResize = () => setWidth(window.innerWidth);
  window.addEventListener("resize", onResize);
  return () => window.("resize", );
}, );

Reset the editor's local draft when a different record is selected — without a sync effect.

Flag
<DescriptionEditor ={record.id} description={record.description} />

Predict the output

function Editor() {
  const [text, setText] = useState("");
  const [count, setCount] = useState(0);

  useEffect(() => { setCount(text.length); }, []); // note the deps

  return (
    <>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <span>{count}</span>
    </>
  );
}

// The user types "hello".
Flag
function Editor({ description }) {
  const [text, setText] = useState(description);
  useEffect(() => { setText(description); }); // no dep array
  return <input value={text} onChange={(e) => setText(e.target.value)} />;
}

// description is "Draft". The user types "X" at the end.
Flag
function Widget() {
  const [resizes, setResizes] = useState(0);
  useEffect(() => {
    window.addEventListener("resize", () => setResizes((r) => r + 1));
  }); // no deps, no cleanup
  return <span>{resizes}</span>;
}

// Production build (no StrictMode). The window is resized twice.
Flag

Challenge

Untangle the three effects in DescriptionEditor

Flag

DescriptionEditor has three useEffects. Only one of them should exist — and that one is written wrong. Symptoms: you can't type, the character count never moves, and a single window resize bumps the counter by more than one.

Your task

Edit DescriptionEditor.tsx so the tests pass:

  • The input starts from the description prop
  • Typing sticks — the value doesn't revert
  • chars matches the current text on every keystroke, in that keystroke's own render — no second render to catch up
  • Two resize events increment resizes by exactly two, and the listener is removed on unmount

Loading the in-browser editor…

Further reading