What you'll learn
By the end of this you'll understand the most common React hook pitfalls, how to spot and fix stale closures, when derived state is better than a second piece of useState, how to write a custom hook that actually hides complexity, and why reaching for useMemo and useCallback by default can make your code worse instead of better. You'll leave with patterns you can apply to existing code today.
These aren't abstract rules. They're the patterns that come up over and over in real codebases — the ones that look fine until they silently break something, usually in production.
Who this is for
- You've been writing React for a while but hooks still occasionally surprise you — a
useEffectfires at the wrong time, or a state update doesn't stick - You've heard "custom hooks" but you're not sure when extracting one is actually worth it
- You want to understand dependency arrays properly, not just "put everything in there and trust ESLint"
You can skip this if you're already writing custom hooks regularly. Jump straight to the useMemo/useCallback section if that's what you're here for.
What is a hook?
A hook is a function whose name starts with use and that can call other hooks internally. Hooks let you tap into React's state and lifecycle features from inside a function component.
Plain English: they're how a function component gets memory (state) and the ability to run side effects — data fetching, subscriptions, timers, anything that touches the world outside React.
Simple idea: useState gives your component a variable that persists across re-renders. useEffect runs code after React has painted the screen. Everything else builds on those two.
Prerequisites
- You can write a React function component and pass props
- You've used
useStateanduseEffectbefore, even if they've bitten you - TypeScript basics help but aren't required to follow along
Key terms
Stale closure — when a function inside useEffect or an event handler captures an old copy of a variable because that variable wasn't listed in the dependency array.
Derived state — a value you can calculate from existing state rather than storing it separately. If you can compute it, don't store it.
Custom hook — a function starting with use that calls React hooks internally. The main way to share stateful logic between components.
Cleanup function — the function returned from a useEffect callback. React runs it before the component unmounts and before the effect re-runs.
Dependency array — the second argument to useEffect, useMemo, and useCallback. Controls when they re-run or recompute.
React Compiler — a build-time tool that can automatically memoize components and values where it determines it's safe. Available as an opt-in in recent React versions.
Setup from zero
Step 1 — A minimal counter to see the pitfall
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
This works fine on the surface. But move that update inside an async callback and count might be stale by the time it runs.
Step 2 — The functional updater form
// Risky: count captured at call time, may be stale
setCount(count + 1);
// Safe: React guarantees prev is the latest value
setCount((prev) => prev + 1);
Use the functional form whenever new state depends on old state, especially inside event handlers and timers.
Step 3 — Cleanup in useEffect
useEffect(() => {
const id = setInterval(() => setCount((c) => c + 1), 1000);
return () => clearInterval(id); // cleanup function
}, []);
That return value is the cleanup function. React calls it when the component unmounts and before the effect re-runs. Skip it and you'll leak intervals — they keep running after the component is gone.
The mental model
Think of useEffect as a synchronisation tool, not a lifecycle method. The question isn't "when should this run," it's "what external thing should stay in sync with this state?" The dependency array is the answer. If a value affects what the effect does, it belongs in there.
Step-by-step
Avoiding derived state
// Two pieces of state tracking the same thing — they'll drift
const [items, setItems] = useState<string[]>([]);
const [count, setCount] = useState(0); // redundant
// Calculate it instead
const [items, setItems] = useState<string[]>([]);
const count = items.length; // derived, not stored
Every time you store something derivable in its own useState, you create a synchronisation problem. Those two values will drift apart at some point — it's just a matter of when.
The stale closure trap
// Broken: message is stale inside the effect
const [message, setMessage] = useState("hello");
useEffect(() => {
const id = setTimeout(() => {
console.log(message); // always logs "hello" even after state changes
}, 2000);
return () => clearTimeout(id);
}, []); // empty array means message is captured once and never updated
Fix: add message to the dependency array. The effect re-runs when message changes and the closure is fresh.
useEffect(() => {
const id = setTimeout(() => console.log(message), 2000);
return () => clearTimeout(id);
}, [message]); // correct
Writing a custom hook
A custom hook is just a function that calls hooks internally. Extract one when you see the same stateful logic in two or more components, or when a component is getting cluttered with useEffect calls that belong together.
function useWindowWidth() {
const [width, setWidth] = useState(() => window.innerWidth);
useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener("resize", handler);
return () => window.removeEventListener("resize", handler);
}, []);
return width;
}
// Usage
function Banner() {
const width = useWindowWidth();
return <p>Window is {width}px wide</p>;
}
The component doesn't know about event listeners. It gets a number. That's the point — the hook hides the machinery, the component describes the UI.
Working examples
useLocalStorage hook
function useLocalStorage<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(() => {
try {
const stored = localStorage.getItem(key);
return stored ? (JSON.parse(stored) as T) : initial;
} catch {
return initial;
}
});
const setAndStore = (next: T) => {
setValue(next);
localStorage.setItem(key, JSON.stringify(next));
};
return [value, setAndStore] as const;
}
Little tip: the lazy initialiser form — useState(() => ...) — runs the setup function once. Calling localStorage.getItem inline would run on every render, which is wasteful and breaks SSR. Use it for any initialiser with a cost.
Abort controller in useEffect
useEffect(() => {
const controller = new AbortController();
fetch(`/api/posts/${id}`, { signal: controller.signal })
.then((r) => r.json())
.then((data) => setPost(data))
.catch((err) => {
if (err.name !== "AbortError") setError(err.message);
});
return () => controller.abort();
}, [id]);
When id changes before the first fetch completes, cleanup aborts the in-flight request. Without it you can get a race condition where an older response overwrites a newer one — a bug that only shows up under fast clicking or slow networks.
Patterns / when to use
When to extract a custom hook — whenever you have two or more hooks working together to produce a single value or behaviour, and you'd want to test that logic separately from the UI.
When NOT to reach for useMemo / useCallback — wrapping every computed value in useMemo and every function in useCallback doesn't make your app faster; it adds memoization overhead and makes the code harder to read.
Memoize when the cost of recalculating is measurable, or when referential stability matters — a function passed to a dependency array, or a value passed to a child wrapped in React.memo.
And — if you're on a project that uses the React Compiler, some of this is less of a concern. The compiler can insert memoization automatically where it's safe. Check your build config before manually sprinkling useMemo across a codebase.
Common mistakes
Empty dependency array when the effect uses state — [] means "run once on mount." If the effect uses any state or props, they'll be stale forever. The react-hooks/exhaustive-deps ESLint rule catches this automatically; turn it on.
Object or array literals in dependency arrays
// Recreates a new object every render — effect runs every render
useEffect(() => fetchData(options), [{ sort: "asc" }]);
// Fix: move it outside the component, or useMemo
const options = useMemo(() => ({ sort: "asc" }), []);
React compares dependencies by reference, not value. A new object literal is never equal to the previous one, so the effect runs on every render.
Forgetting cleanup — subscriptions, intervals, and event listeners survive component unmounts unless you explicitly tear them down. Always return a cleanup function from effects that set these up.
Troubleshooting
"Too many re-renders" — you're calling setState unconditionally in the render body or inside a useEffect that's creating a loop. Check whether the effect has a dependency it's also modifying.
State update on an unmounted component — an async callback resolves after the component unmounts and tries to set state. Use an abort controller or a mounted flag and skip the update if the component is gone.
Hook called conditionally — hooks must be called in the same order every render. You can't put useState inside an if block. Put the condition inside the hook body instead.
useEffect fires on every render — the dependency array has object or function literals that get recreated each render. Move stable values outside the component or stabilise them with useMemo / useCallback.
Little tip: React DevTools has a "Highlight updates" toggle in the settings. Turn it on and any component that re-renders will flash. If something flashes on every keystroke when it shouldn't, that's your starting point for investigation.
Checklist
- [ ] Functional updater form used wherever new state depends on old state
- [ ] All values used inside
useEffectare listed in the dependency array - [ ] Every effect that sets up a subscription, timer, or listener has a cleanup function
- [ ] No derived values stored in separate
useState - [ ] Objects and arrays in dependency arrays are stable references
- [ ] Custom hook extracted when the same stateful pattern appears in two or more components
- [ ]
useMemo/useCallbackadded only where there's a measured reason - [ ]
react-hooks/exhaustive-depsESLint rule enabled
Practice task
Build a useDebounce hook that takes a value and a delay in milliseconds and returns the debounced value. Use it in a search input: the input updates every keystroke, the debounced value only updates after the user stops typing. Add cleanup that cancels the pending timer when the value changes. Then build a useIsOnline hook that tracks navigator.onLine, updates on the browser's online and offline events, and removes those listeners on unmount.
FAQ
Should I define functions inside or outside useEffect?
If the function is only called inside the effect, define it there. If it's needed elsewhere too, define it outside and wrap it with useCallback so its reference stays stable.
Is useReducer better than useState for complex state?
When state has multiple sub-values that change together, or when the next state depends on the previous in non-trivial ways, useReducer is easier to follow and test. It's not always needed — but it's the right tool when updates get complicated.
Can custom hooks return JSX?
Technically yes, but don't. Hooks return data and callbacks, not markup. A function that returns JSX is a component.
What's the difference between useEffect and useLayoutEffect?useLayoutEffect runs synchronously after DOM mutations and before the browser paints. Use it only when you need to read layout (dimensions, scroll position) before the screen updates. Default to useEffect for everything else.
What to learn next
- React performance — now that the hook patterns are solid, the companion post covers where performance actually breaks and how to measure it
- TanStack Query and SWR — both are great examples of custom hook design; read the source once you're comfortable with the basics
- Server Actions — a different mental model for mutations that sidesteps client-side hooks for server data changes
Related on Baseline
- [React performance checklist](/developers/react/react-performance-checklist)
- [React Server Components explained](/developers/react/react-server-components-explained)
- [Next.js App Router guide](/developers/nextjs/nextjs-app-router-guide)
Takeaways
The big hook pitfalls come down to three things: stale closures from incomplete dependency arrays, derived state that drifts out of sync, and missing cleanup that leaks memory. Fix those and you'll fix the majority of the weird hook bugs you'll encounter. Custom hooks are the right tool for sharing stateful logic — extract one when the same pattern appears twice. Resist the reflex to useMemo everything.
If you remember only one thing: dependency arrays are a correctness tool, not an optimisation tool. Include everything the effect uses, clean up after effects that set things up, and most of the hard problems go away on their own.