Untangling a component with too many effects
- 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
Read a crowded component by naming each effect
FlagWhen 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:
| Kind | Example | Verdict |
|---|---|---|
| Subscribe to something external | addEventListener, a socket, a store | ✅ a real effect — needs cleanup |
| Imperative DOM call | .focus(), measuring, scroll position | ✅ a real effect |
| Fetch / write to a server | load on mount, autosave | ⚠️ effect, but a data library usually owns it |
| Compute state from other state/props | setCount(text.length) | ❌ not an effect — derive during render |
| Copy a prop into state | setText(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.
Auditing a real four-effect component
FlagHere'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.
Dependency arrays, StrictMode, and extracting the effect
FlagThe 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?
FlagWhat does useEffect(() => { setText(description); }) — no dependency array — do?
A form's local draft state must reset when the parent selects a different record. What's the idiomatic fix?
FlaguseEffect(() => { window.addEventListener("resize", () => setWidth(window.innerWidth)); }) — what goes wrong?
In development (React 18+, StrictMode) your effect runs setup, cleanup, then setup again on mount. What's the right response?
FlagAn effect re-runs on every render because onCommit (an inline arrow from the parent) is in its dependency array. What's the WRONG fix?
Fill in the blank
Delete the derived-state effect: compute the character count during render.
Flagconst [text, setText] = useState(""); const count = .;
Subscribe to window resize correctly: a named handler, a cleanup, and deps that make it run once.
FlaguseEffect(() => { 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".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.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.Challenge
Untangle the three effects in DescriptionEditor
FlagDescriptionEditor 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
descriptionprop - Typing sticks — the value doesn't revert
charsmatches the current text on every keystroke, in that keystroke's own render — no second render to catch up- Two resize events increment
resizesby exactly two, and the listener is removed on unmount