Memoization in React: memo, useMemo, useCallback
Why React re-renders a whole subtree, why a new object or function each render defeats React.memo, when memoizing pays off, and what the React Compiler changes.
When a component's state changes, React re-runs that component and every component below it, whether their props changed or not. React.memo lets a child skip that work when its props are shallowly equal to last time, and useMemo / useCallback keep objects and functions the same reference between renders so that equality check can succeed. Memoization is a cache with a cost: it pays off for expensive work and for stable references that something else compares, and is overhead everywhere else.
Context
React's founding idea (2013) was that you describe the whole UI for the current state and let the library work out what changed in the DOM (Document Object Model). The price is that "describe the whole UI" means calling your component functions again, top-down, on every update. Class components got an escape hatch in shouldComponentUpdate and then PureComponent, which skipped a render when props and state were shallowly equal. Function components got the same through React.memo in React 16.6 (2018) and the useMemo and useCallback hooks in React 16.8 (2019). In 2025 the React Compiler started inserting this memoization automatically at build time.
You have met the problem if typing in one input made an unrelated chart stutter, if the React DevTools "Highlight updates" option lit up the whole page on every keystroke, or if a linter told you a function in an effect's dependency list "changes on every render". The smallest version:
const Chart = memo(function Chart({options}: {options: Opts}) {
/* expensive drawing */
})
function Dashboard() {
const [q, setQ] = useState('')
return (
<>
<input value={q} onChange={e => setQ(e.target.value)} />
{/* a brand-new object on every keystroke: memo never skips */}
<Chart options={{theme: 'dark'}} />
</>
)
}- Render
- React calling your component function to get its output. Not the same as touching the DOM: a render can produce no DOM changes at all.
- Commit
- The phase where React applies the differences from a render to the real DOM and runs effects.
- Referential equality
- Two values are the same object in memory (Object.is). Two {} literals with identical contents are not equal.
- Shallow comparison
- Comparing each prop with Object.is, one level deep. React.memo does this by default.
- Memoization
- Caching a computed result and returning the cached one while the inputs are unchanged.
Why it matters
Most React slowness in real apps is not one slow component but a cheap update that re-renders a large subtree: a search box at the top of a page that re-renders a thousand-row table on every keystroke, or a context provider whose value is a new object each render, so every consumer re-renders. That shows up as input lag and poor INP (Interaction to Next Paint). The opposite failure is just as common: codebases where every value is wrapped in useMemo, which adds comparison work and noise and often still does not prevent a render because one prop in the chain is unstable.
Why components re-render
A component re-renders for exactly three reasons: its own state changed, a context it reads changed, or its parent re-rendered. "Its props changed" is not on the list. When a parent renders, React calls every child it returns, with new or identical props alike, unless the child is wrapped in memo and every prop passes Object.is. Rendering is not the same as updating the DOM: reconciliation usually finds nothing to change. The cost is the function calls and the comparison work, which matters when the subtree is large or a component is expensive.
The catch is in "props equal". JavaScript compares objects, arrays and functions by identity, so an inline {theme: 'dark'}, a [] default or an arrow function written in JSX is a new value on every render, and a memoized child that receives it re-renders anyway. The same identity rule decides whether an effect re-runs: a function in the dependency array that is recreated every render makes the effect run every render.
memo, useMemo and useCallback
The three tools do one thing each. memo wraps a component and skips its render when all props pass a shallow comparison. useMemo caches the result of a calculation between renders and recomputes only when a dependency changes. useCallback caches a function: useCallback(fn, deps) is exactly useMemo(() => fn, deps). The hooks matter for performance mostly because they make memo and effect dependencies work.
const Row = memo(function Row({item, onSelect}: RowProps) {
return <li onClick={() => onSelect(item.id)}>{item.name}</li>
})
function List({items}: {items: Item[]}) {
const [selected, setSelected] = useState<string>()
return items.map(item => (
// new function every render: every Row re-renders
<Row key={item.id} item={item}
onSelect={id => setSelected(id)} />
))
}const Row = memo(function Row({item, onSelect}: RowProps) {
return <li onClick={() => onSelect(item.id)}>{item.name}</li>
})
function List({items}: {items: Item[]}) {
const [selected, setSelected] = useState<string>()
// same function identity across renders
const onSelect = useCallback(
(id: string) => setSelected(id), [])
return items.map(item => (
<Row key={item.id} item={item} onSelect={onSelect} />
))
}| Tool | Pays off for | Wasted on |
|---|---|---|
| memo | A component that renders often with the same props and has a costly subtree | Cheap leaf components; components whose props change every time anyway |
| useMemo | Expensive calculations (filtering 10k rows), objects passed to memo children, context values | Cheap expressions; values nothing compares by identity |
| useCallback | Callbacks passed to memo children or used in effect dependencies | Handlers passed to plain DOM elements such as a button |
Context values
Every component that reads a context re-renders when the provider's value changes identity, and memo does not block that. A provider written as value={{user, logout}} creates a new object on every render of the provider, so all consumers re-render each time. Memoize the value, or split one large context into several smaller ones that change independently.
Fix the structure before you memoize
Often the cheapest fix is to change who owns the state rather than to add caching. If the state that changes lives lower in the tree, fewer components are below it. And elements passed in as children were created by the parent, so they are the same elements when a wrapper re-renders and React skips them without any memo.
function Page() {
const [q, setQ] = useState('') // lives at the top
return (
<>
<input value={q} onChange={e => setQ(e.target.value)} />
<Results query={q} />
<ExpensiveSidebar /> {/* re-renders per key */}
</>
)
}function Page() {
return (
<>
<Search /> {/* owns q now */}
<ExpensiveSidebar /> {/* not below the state */}
</>
)
}
function Search() {
const [q, setQ] = useState('')
return (
<>
<input value={q} onChange={e => setQ(e.target.value)} />
<Results query={q} />
</>
)
}- 1Measure. Record an interaction in the React DevTools Profiler and look at which components rendered, how long each took, and the "why did this render?" reason (enable it in the Profiler settings).
- 2Move state down: put the changing state in the smallest component that needs it, so the expensive part is a sibling rather than a child.
- 3Lift content up: let a stateful wrapper receive the expensive part as
children. The wrapper re-renders; the children elements are unchanged, so React skips them. - 4Only then memoize: wrap the expensive component in
memoand stabilise each prop it receives withuseMemooruseCallback. One unstable prop undoes the rest. - 5Measure again to confirm the render disappeared from the flame graph and the interaction got faster.
What the React Compiler changes
The React Compiler is a build-time plugin (Babel, with integrations for Vite, Next.js and others) that analyses components and hooks and inserts memoization for values, callbacks and JSX automatically, at a finer grain than you would write by hand. Its 1.0 stable release came in 2025, and it works with React 17 and later. With it enabled, most manual useMemo, useCallback and memo becomes unnecessary for new code, and inline objects and arrow functions stop breaking memoized children.
It does not change the rules underneath. It relies on components being pure and following the Rules of React; code that mutates props or reads changing values during render is skipped or can behave differently. Manual hooks still have a role as an explicit tool, for example to keep an effect dependency stable on purpose, and understanding referential equality is still how you read the Profiler when something re-renders that should not.
import type {NextConfig} from 'next'
const config: NextConfig = {
// stable since Next.js 16; needs babel-plugin-react-compiler
reactCompiler: true,
}
export default configPitfalls
- memo with one unstable prop
Wrapping a component in
memodoes nothing if any prop is a new object, array, inline function or JSX element each render, because the shallow comparison fails on that one prop. Check every prop at the call site, includingchildrenand style objects. - Memoizing everything by default
Each
useMemocosts a dependency comparison and keeps the old value in memory on every render, and the code gets harder to change. For cheap values the cache costs more than recomputing. Memoize what the Profiler shows is slow or what something downstream compares by identity. - Wrong dependency lists
Leaving a dependency out to "keep it stable" freezes the function over an old render's values, the stale closure bug. Adding an object that is recreated every render makes the memo useless. Keep the exhaustive-deps lint rule on, and stabilise the dependency itself, or use a functional state update so the dependency is not needed.
- Using useMemo for correctness
React may throw the cached value away, so logic that breaks when a memoized value is recreated (a connection opened once, an ID generated once) is a bug waiting to happen. Use a ref, a state initializer or move the work to an effect.
- A context value rebuilt every render
A provider passing a new object re-renders every consumer on every provider render, and
memoon consumers cannot stop it. Memoize the value or split the context by how often each part changes.
Interview questions
Q1When does a React component re-render?
When its state changes, when a context it reads changes, or when its parent re-renders. Props changing is not a separate trigger: a child re-renders with its parent regardless of props unless it is wrapped in memo and every prop is equal by Object.is. A render is a function call plus reconciliation; it only touches the DOM if the output differs.
Q2What is the difference between useMemo and useCallback?
useMemo caches the value a function returns; useCallback caches the function itself. useCallback(fn, deps) is the same as useMemo(() => fn, deps). Both return the cached thing while the dependencies are unchanged, which matters mainly when it is passed to a memoized child or used as an effect dependency.
Q3You wrapped a component in React.memo and it still re-renders. What happens and how do you fix it?
One of its props has a new identity on every parent render: an inline object, array, arrow function or JSX passed as children. The shallow comparison fails on that prop, so memo renders anyway. I would find it with the Profiler's "why did this render", then stabilise it with useMemo or useCallback, hoist constants outside the component, or restructure so the prop is not needed.
Q4Walk me through optimizing a search page where typing lags.
First I record typing in the React DevTools Profiler to see what renders per keystroke and what is slow. Usually the query state sits at the page level, so I move it into the search component and keep the expensive parts as siblings or children passed in. If the results list itself is expensive, I memoize it with stable props, and for very long lists I virtualise rendering or use useDeferredValue so typing stays responsive while results catch up. Then I measure again.
Q5When would you not use useMemo?
For cheap computations and for values nothing compares by identity, such as a string or a handler passed straight to a DOM element. There the dependency comparison and retained memory cost more than recomputing. I also would not use it for anything that must run exactly once, because React treats it as a cache it can drop.
Q6Why can a context provider make the whole app re-render?
Every consumer re-renders when the provider's value changes identity, and memo on consumers does not block it. If the provider builds its value as an object literal, each render of the provider creates a new one. Memoizing the value, or splitting the context into parts that change independently, limits re-renders to consumers of the part that actually changed.
Q7How does the React Compiler change this?
It memoizes components, values and callbacks automatically at build time, so most manual memo, useMemo and useCallback becomes unnecessary and inline objects stop breaking memoized children. It assumes components follow the Rules of React, so impure render logic gets skipped or misbehaves. The mental model of referential equality still matters for debugging re-renders and for intentional effect dependencies.
- A parent render re-renders every child; props changing is not the trigger. memo opts a child out when all props are shallowly equal.
- Objects, arrays and functions are compared by identity, so anything created inline is a new value every render and defeats memo and effect deps.
- useMemo caches a value, useCallback caches a function; both exist mainly to make memo and dependency arrays work.
- Measure with the Profiler, then try moving state down or passing children before memoizing.
- useMemo is a cache React may drop, never a correctness guarantee; use refs or initializers for run-once work.
- The React Compiler automates memoization for code that follows the Rules of React, but the identity model is still how you debug re-renders.