Overview
Memoization in React means reusing a previous result instead of doing the same work again on every render. It can apply to components with memo, calculated values with useMemo, and function references with useCallback. Avoiding needless rerenders means reducing render work that does not change the visible UI or that repeatedly recalculates expensive values.
This matters because React applications often slow down from large component trees, expensive list filtering, unstable object and function props, broad context providers, duplicated state, and effects that trigger extra updates. A rerender is not automatically bad. React can render components and still avoid unnecessary DOM mutations during the commit phase. The problem is unnecessary expensive render work, not every function call.
In interviews, this topic tests whether a candidate understands React's render model, referential equality, memoization trade-offs, profiling, context behavior, and practical performance design. Strong answers avoid both extremes: they do not add memoization everywhere, and they do not ignore real performance problems in large forms, tables, dashboards, editors, or frequently updating UI.
The practical goal is to first make state local, rendering pure, and props stable by design. Then measure or identify a real bottleneck and add the smallest memoization that improves the slow path.
Core Concepts
Render Work vs DOM Work
React rendering is the process of calling components to calculate the next UI. Committing is the process of applying the necessary DOM changes. A component can rerender without causing a DOM change.
function Clock({ time }: { time: string }) {
return (
<>
<h1>{time}</h1>
<input />
</>
);
}
When time changes, Clock rerenders. React compares the new output with the previous output and updates only what changed. The <input> DOM node can stay in place.
For interviews, the important distinction is:
- Renders are how React calculates UI.
- Commits are how React changes the DOM.
- A rerender is not always a performance bug.
- A needless rerender matters when it causes expensive calculations, slow component rendering, layout work, network side effects, or visible lag.
Common Render Triggers
A component renders when:
- It mounts for the first time.
- Its state changes.
- One of its ancestors renders and React renders the child as part of that subtree.
- A context value it reads changes.
- An external store subscription tells React the snapshot changed.
- Its key changes and React treats it as a different component.
In development, Strict Mode can intentionally call render logic more than once to find impure render code. Do not confuse this development behavior with production performance.
What Counts as a Needless Rerender
A needless rerender is render work that could have been avoided without changing correct behavior.
Examples:
- A heavy chart rerenders when an unrelated search input changes.
- A memoized child receives a new object literal every render, so memoization never helps.
- A context provider creates a new
{ state, dispatch }object every render, causing all consumers to update. - A component stores derived state in
useState, then uses an effect to recalculate it, causing an extra render. - A table filters and sorts thousands of rows on every keystroke when the filtered result could be memoized or deferred.
Not every repeated render is worth fixing. A small button rerendering is usually cheaper than adding complex memoization.
Optimize State Shape First
Before adding memoization, improve the component design:
- Keep transient state local instead of lifting it to a high parent.
- Split large components so updates affect smaller subtrees.
- Avoid duplicated derived state.
- Move expensive logic out of render only when it is actually expensive.
- Remove effects that only synchronize values that could be calculated during render.
- Pass children as JSX when a wrapper owns state but should not force child recalculation.
function Panel({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<section>
<button onClick={() => setOpen((value) => !value)}>Toggle</button>
{open && children}
</section>
);
}
When Panel updates its own state, the child JSX identity can help avoid unnecessary work below the wrapper, depending on how the tree is structured. This is often cleaner than memoizing every nested component.
memo
memo creates a memoized component. React can skip rerendering the component when its parent rerenders and the component's props are the same as the previous render.
import { memo } from "react";
type ProductRowProps = {
id: string;
name: string;
price: number;
};
const ProductRow = memo(function ProductRow({
id,
name,
price,
}: ProductRowProps) {
return (
<tr>
<td>{id}</td>
<td>{name}</td>
<td>{price}</td>
</tr>
);
});
Important rules:
memocompares props shallowly by default usingObject.is.memois a performance optimization, not a correctness tool.- A memoized component still rerenders when its own state changes.
- A memoized component still rerenders when a context value it reads changes.
memois ineffective if props are always new references.
// Breaks memo because columns is a new array on every render.
<ProductTable columns={["name", "price"]} rows={rows} />
Better:
const productColumns = ["name", "price"];
function ProductsPage({ rows }: { rows: Product[] }) {
return <ProductTable columns={productColumns} rows={rows} />;
}
Or, if the value depends on reactive inputs:
const columns = useMemo(
() => getVisibleColumns(role),
[role]
);
return <ProductTable columns={columns} rows={rows} />;
useMemo
useMemo caches the result of a pure calculation between renders until one of its dependencies changes.
const visibleProducts = useMemo(
() => products.filter((product) => product.name.includes(query)),
[products, query]
);
Use useMemo when:
- The calculation is expensive enough to matter.
- The calculated value is passed to a memoized child.
- A stable object or array is needed as a dependency for another hook.
- Recomputing the value on every render causes measurable lag.
Avoid useMemo when:
- The calculation is trivial.
- The value is only used once and is cheap to recreate.
- You are trying to hide incorrect dependency logic.
- You are caching impure work such as mutation, logging, or network calls.
Bad:
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);
This is usually unnecessary because string concatenation is cheap.
Better:
const fullName = `${firstName} ${lastName}`;
useCallback
useCallback caches a function reference between renders until its dependencies change.
const handleSelect = useCallback((id: string) => {
setSelectedId(id);
}, []);
return <ProductList products={products} onSelect={handleSelect} />;
useCallback does not prevent the parent component from rendering. It is useful when:
- The callback is passed to a memoized child.
- The callback is a dependency of another hook.
- A custom hook returns stable callback references to consumers.
If a callback only updates state from previous state, use the updater form to avoid unnecessary dependencies.
const addItem = useCallback((name: string) => {
setItems((current) => [...current, { id: crypto.randomUUID(), name }]);
}, []);
This is better than depending on items and recreating the callback each time the list changes.
Referential Equality
React compares props and dependencies by identity for objects, arrays, and functions. Two object literals with the same fields are still different references.
Object.is({ pageSize: 20 }, { pageSize: 20 }); // false
Object.is(["name"], ["name"]); // false
Object.is(() => {}, () => {}); // false
This matters because memoization often fails when props are recreated during render.
Bad:
<DataGrid
rows={rows}
options={{ pageSize: 20, density: "compact" }}
onRowClick={(row) => setSelected(row.id)}
/>
Better:
const gridOptions = useMemo(
() => ({ pageSize: 20, density: "compact" as const }),
[]
);
const handleRowClick = useCallback((row: Row) => {
setSelected(row.id);
}, []);
<DataGrid rows={rows} options={gridOptions} onRowClick={handleRowClick} />;
Immutability and Structural Sharing
Memoization works best when state updates preserve references for unchanged data. This is called structural sharing.
Bad:
setProducts((current) =>
current.map((product) => ({ ...product }))
);
This recreates every product object, so memoized rows cannot tell which rows actually changed.
Better:
setProducts((current) =>
current.map((product) =>
product.id === updated.id ? { ...product, price: updated.price } : product
)
);
Only the changed item gets a new reference. Unchanged rows keep their references, which helps memoized child components and selectors.
Context and Memoization
Context changes rerender all consumers that read that context value. memo does not block a rerender caused by context the component uses.
Bad:
function AppProviders({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
return (
<AuthContext value={{ user, setUser }}>
{children}
</AuthContext>
);
}
The provider value is a new object every render.
Better:
function AppProviders({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const value = useMemo(
() => ({ user, setUser }),
[user]
);
return (
<AuthContext value={value}>
{children}
</AuthContext>
);
}
For larger applications, split context by update frequency or responsibility:
AuthStateContextforuser.AuthActionsContextfor stable actions.ThemeContextfor visual theme.- Feature-specific contexts instead of one global application context.
This keeps unrelated changes from rerendering unrelated consumers.
Custom Comparison Functions
memo accepts an optional comparison function.
const Chart = memo(
function Chart({ points }: { points: Point[] }) {
return <ExpensiveChart points={points} />;
},
(prev, next) => prev.points === next.points
);
Custom comparison can help for very specific cases, but it is risky:
- The comparison can be more expensive than rendering.
- Deep equality can freeze the UI on large objects.
- Returning
trueincorrectly can show stale UI. - Ignoring function props can preserve stale closures.
Prefer stable props and structural sharing before custom comparison.
Profiling Before Optimizing
Use React DevTools Profiler or the <Profiler> component to identify expensive commits and components. The useful questions are:
- Which interaction is slow?
- Which components render during that interaction?
- Which components take the most time?
- Are they rendering with the same props?
- Would moving state, splitting components, or memoization reduce work?
import { Profiler } from "react";
function onRender(
id: string,
phase: "mount" | "update" | "nested-update",
actualDuration: number,
baseDuration: number
) {
console.log({ id, phase, actualDuration, baseDuration });
}
export function App() {
return (
<Profiler id="ProductsPage" onRender={onRender}>
<ProductsPage />
</Profiler>
);
}
actualDuration helps show how much time was spent rendering for the current update. baseDuration estimates the cost of rendering the subtree without memoization. A falling actual duration compared with base duration can indicate useful memoization.
React Compiler
React Compiler can automatically apply memoization-style optimizations in supported builds, reducing the need for manual memo, useMemo, and useCallback. Interview answers should mention it without assuming every codebase has adopted it.
Even with compiler support, the fundamentals still matter:
- Components and hooks must stay pure.
- State should be placed close to where it is used.
- Expensive work should be understood and measured.
- Effects should not create unnecessary render chains.
- Manual memoization may still appear in existing codebases and library boundaries.
Practical Optimization Flow
A good optimization flow is:
- Reproduce the slow interaction.
- Profile the render path.
- Check for broad state or context updates.
- Remove derived state and effect-driven render loops.
- Split components around update boundaries.
- Stabilize props only where it helps.
- Add
memo,useMemo, oruseCallbackto the smallest useful area. - Re-profile to confirm the improvement.
Common Mistakes
Common mistakes include:
- Adding
memoto every component without measuring. - Using
useMemofor cheap calculations. - Passing new object, array, or function props to memoized children.
- Using
useCallbackwithout a memoized child or hook dependency reason. - Omitting dependencies to keep a value stable.
- Adding deep custom comparisons that cost more than rendering.
- Treating rerenders as correctness bugs.
- Ignoring context updates.
- Mutating state in place, which can break both rendering and memoization assumptions.
Best Practices
Best practices include:
- Keep rendering pure.
- Keep state as local as practical.
- Prefer simple props over large object props.
- Preserve references for unchanged items.
- Use updater functions to reduce callback dependencies.
- Memoize provider values when passing objects through context.
- Split context by responsibility and update frequency.
- Profile before and after optimization.
- Use virtualization for very large lists instead of relying only on memoization.
- Remove memoization that no longer helps.