In a React app, a developer stops an effect from running twice under <StrictMode> by adding `const didRun = useRef(false)` and returning early when it is already true. Why do reviewers treat that as a red flag, and what should be done instead?
answer
- silences the report, keeps the bug
- refs die with the component instance
- symmetry: every setup has a release
- blocks legitimate re-synchronization later
- one-time user actions belong in handlers
basics
~20 sThe ref guard suppresses the warning, not the defect. StrictMode's second run only revealed that the effect never undoes its own setup, and that leak still happens on any real remount in production. The fix is to write the cleanup.
solid answer
~40 sA `didRun` ref silences the symptom while leaving the bug in place. StrictMode's extra mount is a test: it runs setup, cleanup, setup, and a correct effect comes out of that unchanged. If the second run breaks something, the effect is not idempotent under teardown — usually it is missing a cleanup. The guard hides that from you in development, but production still unmounts and remounts components for real, and on the second real mount the guard has been reset along with the ref, so the leak returns anyway. Worse, the guard makes an effect that deliberately never re-synchronizes, which is exactly wrong if a dependency later changes. Write the cleanup that undoes the setup instead: clear the interval, remove the listener, close the connection, disconnect the observer.
code
javascript · 28 linesimport { useEffect, useRef, useState } from 'react';
// Suppresses the symptom: no cleanup, so the interval survives every teardown.
function Guarded() {
const didRun = useRef(false);
const [n, setN] = useState(0);
useEffect(() => {
if (didRun.current) return;
didRun.current = true;
setInterval(() => setN((v) => v + 1), 1000);
}, []);
return n;
}
// Fixes the defect: setup and cleanup are symmetric, so any number of
// mount/unmount cycles leaves exactly one interval alive.
function Correct() {
const [n, setN] = useState(0);
useEffect(() => {
const id = setInterval(() => setN((v) => v + 1), 1000);
return () => clearInterval(id);
}, []);
return n;
}go deeper
Be able to say that the guard hides the problem rather than fixing it, and that the real answer is to return a cleanup function from the effect. Showing the clearInterval one-liner is enough at this level.
Explain why the guard fails on its own terms: the ref belongs to the component instance, so a genuine remount resets it and the leak reappears. Then show the symmetric setup/cleanup pair and name what each line releases.
Demonstrate the triage judgment — separate effects that merely need cleanup from actions that should never have been in an effect, and say where a real once-only guarantee belongs (event handler, idempotent endpoint, deduplicating data layer) rather than in component state.
Own it as a review standard: cleanup symmetry is an invariant you enforce, and suppression patterns are treated as defects because they convert a loud development failure into a quiet production one. Be ready to describe how you would find and remove existing guards across a large codebase.
## The pattern under review The code in question looks something like this: ```jsx function Ticker() { const didRun = useRef(false); useEffect(() => { if (didRun.current) return; didRun.current = true; setInterval(() => console.log('tick'), 1000); }, []); return null; } ``` It does exactly what the developer wanted: in development the interval is created once instead of twice. It also leaves the codebase strictly worse than before. ## What the second run was telling you StrictMode runs a mounting component's effect as `setup → cleanup → setup`. That sequence is a small automated test of one property: *can this effect be torn down and set back up without leaving residue?* A correct effect passes trivially, because its cleanup releases whatever its setup acquired. The effect above fails, because it acquires an interval and releases nothing. The guard does not change that fact. It changes only how many times you are told about it. React's contract has not moved: an effect must be able to run, clean up, and run again. ## Why the guard does not even work The subtle part, and the part interviewers push on, is that `useRef` state lives with the component instance. When the component genuinely unmounts and later mounts again — a route change, a tab switch, a parent that changed its `key` — a *new* instance is created with a fresh ref whose `current` is `false` again. The guard passes, setup runs, and a second interval joins the first one that was never cleared. The guard buys you silence in development and nothing at all in production. ## The actual fix ```jsx useEffect(() => { const id = setInterval(() => console.log('tick'), 1000); return () => clearInterval(id); }, []); ``` Now the StrictMode sequence is: create interval A, clear interval A, create interval B. One interval is alive, which is the same outcome as a single mount. The effect is now also correct for the real remount, and correct if its dependencies ever change so it must re-synchronize. The general shape is symmetry: every acquisition in setup has a matching release in cleanup. ```jsx useEffect(() => { const onResize = () => setWidth(window.innerWidth); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); ``` ## The second harm: an effect that can never re-synchronize Beyond the leak, the guard hard-codes "run at most once per component instance, ever". Today the dependency array is empty, so that looks harmless. The moment someone adds a dependency — a room id, a user id, a URL — the effect must re-run when that value changes, and the guard silently prevents it. You get a component wired to the *first* value it ever saw, and the bug reads as a data problem rather than an effect problem, which makes it expensive to find. ## When "it must happen once" is genuinely true Sometimes the developer's instinct is right that the action should not happen twice — sending an order, charging a card, appending a row. In almost every such case the conclusion is not "guard the effect" but "this does not belong in an effect". Work triggered by a user action belongs in the event handler for that action; it then runs exactly once per click, needs no guard, and does not care about mounting at all. Where the action really is mount-driven and really is non-idempotent, the durable answers live outside the component: make the operation idempotent on the receiving side (an idempotency key, an upsert), or move the work to a layer that owns its own deduplication — a cache, a request-deduping data layer, a module-scoped registry that survives remounts. Those solutions work under a real remount, which is the property the ref guard lacks. ## How to say it in an interview Name the three points in order: the double run is a diagnostic, not the defect; the guard hides the diagnostic while the leak survives; and the guard is itself a new bug because it blocks legitimate re-synchronization. Then show the cleanup. Reviewers are looking for the reflex that reaches for symmetry rather than suppression.
- The developer argues the guard is fine because the dependency array is empty and will stay empty. What do you say?Two things. First, emptiness is unrelated to the leak: the effect still acquires an interval it never releases, so a real unmount leaves it running. Second, an empty array is a claim about today's code, and the guard turns a future dependency addition into a silent bug rather than a compile-time or lint-time one. The cleanup costs one line and removes both risks.
- Is there any legitimate use of a ref inside an effect to track whether something has happened?Yes — refs are fine for remembering per-instance facts that must not trigger a render, such as whether the first scroll has been performed or the previous value of a prop. What makes the pattern a red flag is specifically using it to skip StrictMode's second setup, because that skips the teardown test rather than storing a fact.
- What if the mount-time action genuinely must not happen twice, like recording a one-off signup event?Then push the guarantee out of the component. If the event is caused by a user action, fire it from that event handler, where it runs once per interaction. If it is genuinely mount-driven, make the receiving side idempotent — an idempotency key or an upsert — or let a deduplicating layer own it. Those hold up under a real remount, which an in-component ref does not.
saying these in an interview costs you the question
- Says the ref guard is the standard StrictMode fix
- Believes a ref survives an unmount and remount
- Treats the second run as noise to be silenced
- Adds the guard instead of returning a cleanup
- Ignores that the guard blocks later re-synchronization