Stale useCallback — User A IDs Sent After User B Logs In
After user B logs in, useCallback still sends user A's ID in API payload because userId was omitted from deps.
- useMemo caches computed values; useCallback caches function references.
- Both preserve referential identity so React.memo can skip re-renders.
- useCallback(fn, deps) is identical to useMemo(() => fn, deps) internally.
- Profiling is essential: memoize only when the profiler shows a real bottleneck.
- The most common production mistake: applying useCallback without React.memo on the child.
- The hardest bug: stale closures from missing dependencies – always enable exhaustive-deps.
Imagine you run a bakery and someone asks for a cake. You could bake a fresh one every single time — even if the recipe and ingredients haven't changed at all. That's wasteful. Instead, you write down the finished cake on a notepad and if the same order comes in with the same ingredients, you just hand over the notepad copy. useMemo does exactly that for computed values. useCallback does the same thing but for the recipe itself — it hands you back the same recipe function so you don't accidentally confuse your staff into thinking it's a brand-new order every time.
Every React developer hits a wall eventually. The app works, the logic is sound, but child components are re-rendering for no apparent reason, expensive calculations are running on every keystroke, and the profiler is showing a wall of orange. The usual suspects? Inline functions and unguarded expensive computations passing straight through to memoized children. useMemo and useCallback are React's built-in escape hatches from this exact problem — but they're also among the most misused hooks in the entire ecosystem.
The core problem both hooks solve is referential instability. In JavaScript, a new function literal or a newly computed object created inside a render cycle gets a brand-new memory address every single time, even if its contents are identical. When you pass that value down as a prop, React.memo and PureComponent see a different reference and trigger a re-render, even though nothing meaningful changed. useMemo caches a computed value between renders. useCallback caches a function definition between renders. Both return the same reference until their dependency array changes.
After reading this article you'll be able to: correctly identify when memoization actually helps versus when it adds overhead with zero gain, confidently explain the difference between useMemo and useCallback at a referential identity level, avoid the three most common production mistakes that turn these hooks into performance liabilities, and write genuinely optimized React component trees that hold up under profiler scrutiny.
Referential Identity — The Root Problem Both Hooks Actually Solve
Before touching either hook, you need a firm grip on why JavaScript's equality model bites React so hard. Primitive values like numbers and strings compare by value. But functions and objects compare by reference — their memory address. Two functions with identical bodies are not equal in JavaScript.
Every time a React component renders, its function body executes top to bottom. Any function or object defined inside that body is born fresh — a new allocation, a new address. That's by design in JavaScript, but it's a silent killer in React's prop-passing model.
React.memo wraps a child component and bails out of re-rendering if its props are shallowly equal to the previous render. Shallow equality means it checks object references, not deep values. So if your parent passes an inline callback or a freshly computed array, React.memo sees a new reference on every parent render and the bail-out never fires. useMemo and useCallback exist specifically to preserve referential identity across renders so that memoized children can actually do their job.
useCallback — Stabilising Function References Across Renders
useCallback(fn, deps) returns a memoized version of the function fn. React guarantees it returns the exact same function reference between renders as long as none of the values in the deps array have changed. The moment a dependency changes, useCallback throws away the cached version and stores the new function.
The mental model: useCallback doesn't make your function faster. It makes your function stable. That stability is what lets React.memo on child components actually bail out. Without it, you're memoizing a child but feeding it a moving target.
A critical internal detail: useCallback(fn, deps) is literally identical to useMemo(() => fn, deps). React's source code even shows this — useCallback is just a convenience wrapper. Understanding this equivalence helps you reason about both hooks from first principles rather than memorising two separate mental models.
The dependency array follows the same rules as useEffect. Every value from the component scope that's used inside the callback — props, state, context values, other variables — must be listed. ESLint's exhaustive-deps rule from eslint-plugin-react-hooks is your best friend here; enable it and treat its warnings as errors.
useMemo — Caching Expensive Computed Values Across Renders
useMemo(() => computeValue(), deps) runs the factory function once, stores the result, and returns that same result on every subsequent render until a dependency changes. It's the hook for when the computation is the bottleneck, not the reference.
The two valid use cases for useMemo are: (1) genuinely expensive computations — think filtering or sorting thousands of records, running a heavy algorithmic transformation, or deriving complex state trees; and (2) referentially stable objects and arrays that need to be passed to memoized children or used in other hooks' dependency arrays.
That second use case is subtler but equally important. If you compute an options object inside a component and pass it to a child wrapped in React.memo, or use that object in a useEffect dependency array, every render produces a new reference, triggering unnecessary effects or child renders. useMemo pins that object to a stable reference.
One internal detail worth knowing: React can and does discard the memoized value in some situations — specifically in development mode during Strict Mode double-invocation, and in future concurrent features where React may interrupt and restart renders. Never rely on useMemo as the single source of truth for side effects or critical logic. It's a performance hint to React, not a guarantee.
Production Patterns, Edge Cases, and the Hidden Costs of Over-Memoizing
Here's the uncomfortable truth: useMemo and useCallback cost something too. Every memoized value consumes memory. Every render still executes the hook, runs the dependency comparison, and decides whether to return the cached value or recompute. For cheap computations and simple primitives, that overhead can easily exceed what you saved.
Over-memoization is a real production pattern that makes codebases harder to read, harder to refactor, and sometimes actually slower. The React core team's recommendation is measured and specific: profile first, memoize where the profiler shows a real problem, and review your memoization regularly as component structures change.
There are three advanced patterns worth knowing. First, memoizing derived context values — if you provide an object through React Context without memoizing it, every context consumer re-renders on every provider re-render even if the values inside the object didn't change. useMemo on the context value object is one of the few cases where memoization pays dividends across a wide component tree. Second, stable refs as an alternative — useRef gives you a stable container for free, and for event handlers that need access to latest state without re-subscribing, combining useRef with useCallback can eliminate deps entirely. Third, the useEvent pattern (RFC stage) — a future hook that aims to give you a stable function reference that always closes over fresh values, solving the deps-vs-stability tension permanently.
Diagnosing Memoization Failures — A Step-by-Step Debugging Workflow
When memoization doesn't work as expected, it's rarely because the hooks themselves are broken. The cause is almost always one of: an unstable dependency, a missing React.memo on the child, or a stale closure. Here's a systematic way to diagnose which one.
Start with React DevTools – locate the memoized child in the component tree and verify it's actually rendered as memo. If the child re-renders when you expect it not to, record a profile and look at the props panel for the child. The 'changed' markers show exactly which prop reference changed.
Next, if a prop that should be stable is flagged as changed, examine its origin in the parent. If it's an inline function, wrap it with useCallback and check that the dependencies are correct. If the prop is an inline object or array, wrap it with useMemo. If the prop is already in useCallback/useMemo but still changes, the culprit is likely a dependency that itself is unstable – an object defined inline inside the dependency array's scope.
Sometimes you need to debug at a lower level. Use a useEffect with console.log of the dependency array values to see when they actually change. This is especially useful for detecting hidden reference changes.
Finally, the stale closure case is the trickiest. If your callback uses a variable from the enclosing scope but you've deliberately omitted it from deps to keep the function stable, the callback will always see the initial value. Use the ref pattern to hold the latest value without triggering deps changes.
The Stale useCallback That Sent Wrong User IDs
userId variable from the component scope. The developer omitted userId from the deps array to keep the function stable, but the callback always captured the initial value of userId (user A's ID). When user B logged in, the handler still referenced user A's ID.userId in the useCallback dependency array. If stability is critical and the dependency changes too often, use the ref pattern: const userIdRef = useRef(userId); useEffect(() => { userIdRef.current = userId; }, [userId]); then inside the callback use userIdRef.current instead of userId directly.- Always include every external variable used inside useCallback in the dependency array – trust exhaustive-deps.
- When you cannot include a dependency without causing unwanted re-renders, use the ref pattern to capture the latest value without recreating the callback.
- Treat exhaustive-deps warnings as blocking errors; stale closures are among the hardest bugs to find because they produce no error, only incorrect behavior.
Key takeaways
Common mistakes to avoid
3 patternsMemoizing everything 'just in case'
Applying useCallback without React.memo on the child
Omitting dependencies to force stability (stale closure bug)
Interview Questions on This Topic
What's the difference between useMemo and useCallback, and can you explain why useCallback(fn, deps) is technically equivalent to useMemo(() => fn, deps)? Walk me through what that means at the memory level.
fn, useMemo(() => fn) stores the function object fn. So useCallback avoids creating an extra wrapper function (the arrow function) even though internally the two calls are identical. In terms of memory usage, useCallback simply stores the function object reference; useMemo stores whatever the factory returns (could be a function, object, etc.). The practical difference is semantic: useCallback signals to the reader that you are intentionally preserving a function reference; useMemo signals that you are preserving a computed value.Frequently Asked Questions
That's React.js. Mark it forged?
6 min read · try the examples if you haven't