Run this yourself. The script that produced every number in this article:
react-memo-render-counts/in the Coding Dunia code-examples repo.
React hooks follow two rules with no exceptions: call them only at the top level of a component (never inside conditions, loops, or nested functions), and only from React function components or other custom hooks. The eslint-plugin-react-hooks package enforces both. Beyond the rules, production bugs cluster in three places: stale closures in useEffect, missing cleanup in effects that subscribe to something, and memoization that costs more than it saves. useEffect synchronizes a component with an outside system (network, timers, subscriptions). It isn't a replacement for lifecycle methods, and it isn't a place to compute values you can compute during render.
Advice about hooks is usually delivered as folklore. "Wrap handlers in useCallback." "Never derive state in an effect." Nobody shows the render count. So I ran the patterns and counted.
What Did the Render Counts Show?
I wrote 25 small components, rendered them in jsdom with React 19.3.1 (production build for timing, development build for StrictMode), and counted how many rows re-rendered when a parent updated for a reason that has nothing to do with the rows. 1,000 rows, one parent, one unrelated counter. Five full runs, every one on a machine shared with other jobs, so the milliseconds below are a range and the counts are exact.
| Pattern (1,000 rows under one parent) | Rows re-rendered | Update time, best of runs |
|---|---|---|
| Plain rows, inline handler | 1000 | 0.44-0.66 ms |
React.memo rows, inline handler | 1000 | 0.50-0.76 ms |
useCallback, rows not memoized | 1000 | 0.38-0.53 ms |
React.memo + useCallback | 0 | 0.17-0.23 ms |
memo + useCallback + inline style={{}} | 1000 | 0.56-0.85 ms |
memo + useCallback + JSX children | 1000 | 0.64-0.92 ms |
memo + context value {} created inline | 1000 | 0.40-0.54 ms |
memo + useCallback + useMemo for the style | 0 | 0.19-0.25 ms |
| Same source, no manual memo, React Compiler on | 0 | 0.01-0.02 ms |
Three things in that table surprised me. First, a defeated memo isn't neutral. It re-rendered all 1,000 rows exactly like no memo, and the failed prop comparison added 11 to 49 percent on bare rows. Second, useCallback by itself rendered the same 1,000 rows as nothing at all. Third, the last row. I'll get to it.
One caveat belongs here and not at the bottom. These rows are bare <li> elements, or in a second pass rows doing about 4 microseconds of arithmetic. At that weight, the memo overhead disappears into noise (a defeated memo added 0.03 to 0.08 ms to updates that took 3.3 to 4.7 ms). Real rows are heavier and real browsers add layout, so treat the ratios as a direction, not a forecast.
Three terms get used loosely in hook discussions, and the looseness is where the confusion starts:
- Stale closure is a function that captured a variable during an earlier render and still reads that old value, because JavaScript closed over the binding rather than tracking the current one.
- Referential stability means a value keeps the same identity across renders, which is what
React.memoand dependency arrays compare on. - Cleanup function is whatever an effect returns; React calls it before the next run and on unmount, and its absence is what leaks subscriptions.
When an effect misbehaves, work through it in this order rather than adding dependencies until the warning stops:
- Decide what external system the effect synchronizes with. If the answer is "none", it probably shouldn't be an effect at all.
- List every value from the render scope the effect body reads. That list is the dependency array, with no exceptions negotiated.
- Ask whether re-running on each of those values is correct. If not, the problem is the effect's shape, not the array.
- Return a cleanup function if the effect subscribed, opened, or scheduled anything.
What Are the Most Common useState Mistakes?
useState has the smallest API of any hook and still produces two bugs that show up constantly: reading the previous value directly instead of through the updater function, and computing expensive initial state on every render when it should run once.
Using previous state incorrectly:
// Wrong - reads stale count from the closure
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
setCount(count + 1); // still reads the original count
}
// After one click: count is 1, not 2
// Correct - always reads the latest state
function handleClick() {
setCount(prev => prev + 1);
setCount(prev => prev + 1);
}
// After one click: count is 2
I clicked each version twice. The direct form went 1, then 2. The updater form went 2, then 4. The bug isn't "clicks get lost", it's that both calls inside one handler read the same snapshot. Use the functional update form whenever the new state depends on the previous value. Always.
Expensive initial state running on every render:
// Wrong - parseStoredUser() runs on every render
const [user, setUser] = useState(parseStoredUser(localStorage.getItem('user')));
// Correct - lazy initializer runs only once
const [user, setUser] = useState(() => parseStoredUser(localStorage.getItem('user')));
I counted the calls over one mount plus 60 re-renders: 61 for the eager version, 1 for the lazy one. React throws the result away after the first render, so those 60 calls produce nothing. Passing a function means React calls it once, on mount.
Both bugs share a root cause: useState looks like a plain assignment, so people reason about it as one. It isn't. The value you read is a snapshot from the render that produced it, and the argument you pass is evaluated by JavaScript before React ever sees it.
How Do You Write useEffect Without the Stale Closure Trap?
Stale closures are the most common source of side effect bugs in React. They happen when an effect captures a value at setup time and that value changes later, but the effect's dependency array doesn't include it.
// Bug: interval always logs the original count
useEffect(() => {
const id = setInterval(() => {
console.log(count); // stale - always 0
}, 1000);
return () => clearInterval(id);
}, []); // missing count in deps
// Fix: include count in deps (but this resets the interval on each change)
useEffect(() => {
const id = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(id);
}, [count]);
// Better fix: use a ref to read latest value without restarting
const countRef = useRef(count);
useEffect(() => {
countRef.current = count;
}, [count]);
useEffect(() => {
const id = setInterval(() => {
console.log(countRef.current); // always fresh
}, 1000);
return () => clearInterval(id);
}, []);
The ref pattern works well for event handlers inside effects that need fresh values without restarting. The key insight: a ref doesn't trigger a re-render when it changes, so reading countRef.current inside the interval doesn't need to be in the dependency array.
Picking between the three versions is a question of what the effect is actually for. If the effect's job depends on the value, include it in the deps and accept the teardown. An interval that logs a count is arguably fine restarting once per second. A WebSocket connection that reconnects on every keystroke is not.
Never silence the lint rule to make the warning go away. When exhaustive-deps complains and the fix feels wrong, the effect usually has the wrong shape, and the rule is pointing at it. React 19.2 added useEffectEvent for the "read the latest value, don't re-subscribe" case, and it replaces most hand-rolled ref plumbing. I measured it: with only a theme prop changing, an effect that listed the theme in its dependencies ran again (2 runs total), and the useEffectEvent version stayed at 1.
Always Return Cleanup
Effects that subscribe to things must clean up:
useEffect(() => {
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then(res => res.json())
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') setError(err);
});
return () => controller.abort();
}, []);
What Does StrictMode Actually Do to Your Hooks?
Everyone says "effects run twice in development". The real list is longer. I mounted one component in a development build, with and without <StrictMode>, and counted every callback:
| Callback | Without StrictMode | With StrictMode |
|---|---|---|
| Render function, on mount | 1 | 2 |
| Render function, on each update | 1 | 2 |
useState initializer | 1 | 2 |
useMemo factory, on mount | 1 | 2 |
| Effect setup / cleanup, on mount | 1 / 0 | 2 / 1 |
| Ref callback attach / cleanup, on mount | 1 / 0 | 2 / 1 |
The factory and the initializer don't repeat on updates, only the render function does. And the fetch example above earns its cleanup here: with StrictMode on, an effect that fetches without an AbortController started 2 requests and applied 2 results, a double request that only ever shows up in development. With the abort cleanup it still started 2 requests, but applied 1 result and saw 1 abort. One request is wasted either way. The cleanup decides whether the wasted one can overwrite the real one.
Should You Derive State in an Effect?
No, and the cost is bigger than "an extra render". Here are the three ways I tried to filter a 20,000-item list by a query that changes:
// 1. Effect + state
const [filtered, setFiltered] = useState(items);
useEffect(() => {
setFiltered(items.filter(it => it.label.includes(query)));
}, [items, query]);
// 2. Computed in render
const filtered = items.filter(it => it.label.includes(query));
// 3. Computed in render, memoized
const filtered = useMemo(() => items.filter(it => it.label.includes(query)), [items, query]);
Per query change, version 1 rendered twice and committed twice. Versions 2 and 3 rendered once and committed once. The detail that makes this more than an efficiency point is in the first commit. I logged the DOM text on every commit, and the first one of version 1 paired the new query with the old result count (the page said the query was item-12345 while still showing the 11,111 matches for item-1). Then the effect fired and fixed it. In a browser, that first state can be painted.
Time didn't separate them consistently. Across five runs the effect version was sometimes the slowest and sometimes tied, because filtering 20,000 short strings cost about as much as the extra render. So the argument against the effect isn't speed. It's that the component briefly shows a lie.
When Does useCallback Actually Help?
useCallback memoizes a function reference across renders. From the table above, it does something only when a second ingredient is present. Alone, it left all 1,000 rows rendering. With React.memo on the row and nothing else unstable, it dropped the count to 0.
The two legitimate cases, then. The first is when the function is itself a dependency of another hook, where an unstable reference makes the dependent effect re-run on every render:
const fetchUser = useCallback(async (id: string) => {
const res = await fetch(`/api/users/${id}`);
return res.json();
}, []); // stable reference
useEffect(() => {
fetchUser(userId).then(setUser);
}, [fetchUser, userId]); // fetchUser won't trigger re-run
The second is passing it to a React.memo-wrapped child.
const handleDelete = useCallback((id: string) => {
setItems(prev => prev.filter(item => item.id !== id));
}, []); // stable - won't cause MemoizedList to re-render
return <MemoizedList items={items} onDelete={handleDelete} />;
And here the table has a warning. A memo child is only as stable as its least stable prop. Add style={{ color: 'teal' }} inline and the row re-rendered 1,000 times. Pass <b>new</b> as children and the same. Provide a context value as { mode: 'dark' } created inline, and every consumer re-rendered even though it was wrapped in memo, because context ignores the memo comparison entirely. Fix the style with useMemo or the context value with useMemo, and the count went back to 0.
Don't wrap every function in useCallback by default. I didn't measure the cost of the hook call in isolation, so I won't give you a nanosecond figure. What I can say is that the version without memo was not slower with it. On bare rows the useCallback-only update came in 10 to 20 percent under plain inline handlers, though I didn't dig into why (my guess is that an unchanged onClick prop gives React less to diff on each <li>).
The test I apply is simple. If removing the useCallback would change behavior or trigger a measurable re-render, keep it. If the only thing it changes is that a function identity stays stable while nothing observes that identity, delete it.
A React.memo child is only as stable as its least stable prop: one inline object, one inline function or one JSX child and all 1,000 rows re-rendered anyway.
When Is useMemo Worth the Overhead?
useMemo caches a computed value between renders. I measured both ends. The cheap end first: a useMemo around a string concatenation, in 1,000 cells that re-rendered every time. It came out 11 to 21 percent slower across five runs, roughly 20 to 80 nanoseconds per cell. That's real and it's also invisible. Nobody has ever shipped a slow app because of 40 nanoseconds.
// Not worth it - string concatenation is cheap
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);
// Worth it when the list is large - filter + sort with localeCompare
const filteredProducts = useMemo(
() =>
products
.filter(p => p.category === selectedCategory && p.price <= maxPrice)
.sort((a, b) => a.name.localeCompare(b.name)),
[products, selectedCategory, maxPrice]
);
The expensive end: that filter-and-sort over lists of different sizes, re-rendered because of an unrelated counter. The time useMemo saved on each render:
| Products | Saved per render |
|---|---|
| 10 | about 0 ms |
| 100 | 0.01-0.02 ms |
| 1,000 | 0.2-0.3 ms |
| 5,000 | 1.4-2.5 ms |
My earlier version of this article put the crossover at "a few thousand array items". The numbers don't support that. The memoized version beat the plain one from about 100 items, and by 5,000 it was saving two milliseconds on an M1. What changes at that point is not whether memoization wins, it's whether you can feel it. Below about a millisecond per render, I wouldn't bother adding the hook. Above it, I would.
A second, better-justified use is referential stability for objects passed to memoized children.
// Without useMemo: new object on every render breaks child memoization
const config = useMemo(
() => ({ threshold: 0.1, rootMargin: '200px' }),
[] // created once
);
return <IntersectionObserverChild config={config} />;
useCallback or useMemo: When Is Each Worth It?
| useCallback | useMemo | |
|---|---|---|
| Memoizes | A function reference | A computed value |
| Worth using when | Passed to a React.memo child, or a dependency of another hook | Computation you can see in the profiler (about 1 ms or more), or referential stability for objects |
| Not worth it when | Nothing downstream compares the identity | Cheap operations like string concatenation |
| With the React Compiler | Mostly unnecessary | Mostly unnecessary |
What Does the React Compiler Do to the Same Code?
This is the last row of the first table, and the one I didn't expect to be so lopsided. I compiled the exact same source file twice with Babel: once as written, once through babel-plugin-react-compiler 1.0.0 (the compiler reached 1.0 in October 2025). All 25 components compiled, with zero bail-outs. Then I ran the nine parent-update patterns again, including the ones with no memo, no useCallback, no useMemo.
Every pattern re-rendered 0 rows. The three that defeat hand-written memo (inline style, JSX children, inline context value) re-rendered 0 as well. And the update took 0.01 to 0.02 ms, against 0.17 to 0.23 ms for the best manual setup. That gap exists because a perfectly memoized parent still walks all 1,000 children and runs memo's prop comparison on each one, roughly 0.2 microseconds per row. The compiled parent caches the whole list element and never visits the rows.
So should you delete your manual memo calls tomorrow? I wouldn't. This was a deliberately simple parent, and I didn't test components that break the rules of React, which is where the compiler bails out and leaves the code as written. The practical advice: on a React 19 codebase with the compiler enabled, reach for useCallback and useMemo only when profiling shows a specific problem it didn't solve, typically a value crossing a context boundary or an object handed to a library that does its own identity checks. On React 18 and earlier, the rules above still apply in full.
Roll the compiler out one feature folder at a time and compare render counts in the React DevTools profiler before and after. It's the same check this article is built on, and it takes ten minutes. The deeper guide is the React Compiler practical guide.
How Do You Write Good Custom Hooks?
Custom hooks are one of the cleanest state management patterns React gives you. They extract stateful logic so it's testable and shareable. If the same useState/useEffect combo appears in two components, pull it into a hook. A hook is also what decides which visual state the user sees at any point (loading, error, success, empty), so the component hierarchy your designer built in Figma maps directly to your hook's state shape.
// Extracted custom hook
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
fetch(url, { signal: controller.signal })
.then(r => r.json())
.then((d: T) => { setData(d); setLoading(false); })
.catch(err => {
if (err.name !== 'AbortError') { setError(err); setLoading(false); }
});
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
// Clean call site
function ProductList() {
const { data, loading, error } = useFetch<Product[]>('/api/products');
}
The <T> generic on useFetch is what lets useFetch<Product[]>(...) infer data as Product[] | null rather than unknown. If that syntax is unfamiliar, our TypeScript generics explained guide walks through the constraints, defaults, and conditional patterns that make typed custom hooks ergonomic instead of ceremonial.
What Are the Rules of Hooks?
Two rules, no exceptions: call hooks at the top level of your React component (never in loops, conditions, or nested functions), and only from function components or other hooks. Built-in hooks like useState and useEffect rely on a stable call order between renders. React breaks the moment you smuggle a hook into a conditional branch: in the React 19.3.1 development build, a second useState placed after an early return threw "Rendered more hooks than during the previous render" the first time the condition flipped. The eslint-plugin-react-hooks package enforces both rules, install it. Effects run after the browser paints, and cleanup runs before the next effect fires or when the component unmounts.
What Should You Read Next?
- React 19 guide - measured behavior of Actions,
useOptimistic,use()and the other React 19 APIs, plus which version shipped what - TypeScript utility types - use Pick, Omit, and Partial to type your hook return values and state objects correctly
- React Hook Form vs Formik - form state is one of the trickiest hook patterns; React Hook Form's uncontrolled approach sidesteps many of the useEffect pitfalls covered here
- Zustand vs Jotai - when local hook state isn't enough, these are the two libraries most React teams reach for next