React hooks
How the useEffect dependency array works
useEffect(() => {
const conn = createConnection(serverUrl, roomId);
conn.connect();
return () => conn.disconnect(); // cleanup
}, [serverUrl, roomId]); // dependencies
The dependency array has exactly three forms. [a, b] re-runs the effect when any listed value changes. [] runs it once after mount. Omitting the array runs it after every render. The cleanup function undoes whatever setup did (unsubscribe, clearInterval, disconnect), and it runs before every re-run as well as on unmount. Setup and cleanup are a mirrored pair.
Rules of Hooks
Hooks are called at the top level of the component, never inside conditions, loops, or nested functions. React identifies hooks by call order, so the order must be identical on every render. A custom hook is a plain function named useSomething that calls other hooks, and every component calling it gets its own independent state. The common misreading here is "custom hooks share state between components". They share logic and nothing else.
What useMemo and useCallback cache
const sorted = useMemo(() => sortBig(items), [items]); // caches a VALUE
const onSave = useCallback(() => save(id), [id]); // caches a FUNCTION
Both keep the cached thing until a dependency changes. Why you would want to cache a function at all comes down to one JS fact: objects and functions are recreated on every render, and {} !== {}. Two structurally identical objects are different references, and React compares by reference. Hold that thought.
When you don't need an effect
Before writing an effect, ask two questions. Can this be computed during render? Derived data needs no effect; calculate it inline. Does this happen because the user did something? Then it belongs in the event handler. Effects exist to synchronize with things outside React: network, subscriptions, timers, the DOM. Most junior-level effect bugs are effects that shouldn't exist in the first place. The famous bug that lives inside legitimate effects, the stale closure, is the middle-level story.
The stale closure bug in useEffect
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []); // ❌ shows 1 forever
The effect ran once, so the interval callback is a closure over the first render's count, which is 0 forever. Every tick computes setCount(0 + 1). Each render has its own snapshot of props and state; a callback sees the snapshot of the render that created it. This is plain JavaScript closure capture. React adds no magic, and the absence of magic is why the bug exists.
The fix hierarchy, strongest first:
- Functional updater:
setCount(c => c + 1). The callback stops readingcount,[]becomes honest, and one interval lives forever. - Add
countto deps. Correct, but it recreates the interval every second (cleanup runs before every re-run). - A ref, for the general "latest value" case.
How React compares effect dependencies
React compares deps with Object.is. Two consequences:
- Object/function deps churn.
const options = {serverUrl, roomId}is a new reference each render, so an effect depending on[options]re-runs every render. Fix by building the object inside the effect and depending on the primitives. - The
exhaustive-depslint derives deps from the code. Suppressing it (like the interval bug above) is lying to React about what the effect reads. If the honest deps cause unwanted re-runs, restructure the code rather than editing the list.
Race conditions in fetch effects
useEffect(() => {
let ignore = false;
fetchUser(id).then((data) => { if (!ignore) setUser(data); });
return () => { ignore = true; };
}, [id]);
Switch id quickly and two responses are in flight; the old one may land last and clobber the state. The cleanup flag (or an AbortController) turns stale responses into no-ops. Related: StrictMode mounts components twice in dev (setup, cleanup, setup again) precisely to shake out effects whose cleanup doesn't truly mirror setup. Working around the double mount with a didRun ref means the effect is wrong, not React.
When useCallback pays for itself
useCallback(fn, deps) is useMemo(() => fn, deps) under a nicer name. It pays for itself in exactly three places: a prop for a memo-wrapped child, a dependency of an effect, or a value returned from a custom hook. Anywhere else it adds overhead and reading cost. The senior notes cover the cases where memoization is the wrong answer entirely.
Why useEffect(fn, []) is not componentDidMount
An effect describes how to start and stop synchronizing with an external system for the current props and state. React may run that cycle once or fifty times, whenever the reactive values change; "mount" and "update" are not in the vocabulary. useEffect(fn, []) reads as "this synchronization depends on nothing". Code written with lifecycle thinking breaks under StrictMode's dev remount, and under any future where React unmounts and restores state more aggressively. Correct effects are idempotent under remount: cleanup truly mirrors setup, so setup then cleanup then setup again is a no-op.
Separating "when to re-sync" from "what to read"
The subtlest dependency problem: an effect that should re-run on roomId but also reads theme. Honest deps [roomId, theme] reconnect the chat on every theme toggle. The pattern that solves it separates reactive triggers from latest-value reads:
function useInterval(callback, delay) {
const cbRef = useRef(callback);
useEffect(() => { cbRef.current = callback; }); // always latest
useEffect(() => {
const id = setInterval(() => cbRef.current(), delay);
return () => clearInterval(id);
}, [delay]); // only delay re-syncs
}
This is the useEffectEvent pattern; the ref version works today. The same split is the backbone of custom hook design: accept reactive values, return stable ones. Name hooks after use cases (useChatRoom), wrap incoming callbacks so consumers don't have to memoize, and memoize what you return, because you are designing someone else's dependency arrays.
When not to use useMemo and useCallback
useMemo and useCallback cost memory, code noise, and a cache-miss risk: React may drop caches under memory pressure, so the cache is a hint, not a semantic guarantee. They pay off only when feeding a memo boundary, an effect dep, or a hook's return value. Interviewers filter cargo-culting with the inverted question "when would you NOT use useCallback?", and the strongest answers add that structural fixes (state colocation, children-as-JSX) often delete the need, and that React Compiler is automating the rest.
Should this effect exist?
The final senior habit is challenging the effect itself:
- derived data: compute it during render
- user actions: handle them in the event handler
- external stores:
useSyncExternalStore - fetch-on-navigation: router or framework loaders
A PR with a suppressed exhaustive-deps and a didRun ref raises a design question before a lint question: should this code be an effect at all?
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.