Have you ever looked at a React component and thought:
โMaybe I should wrap this function with useCallback, this calculation with useMemo, and the component with React.memo?โ
You're not alone.
Memoization is one of the most frequently discussed React performance techniques. But it's also one of the easiest to misuse.
You can add memoization to an application, introduce more complexity, and still fail to improve its performance.
Why?
Because memoization is not about preventing every render. It's about avoiding work that doesn't need to happen again.
Let's understand how it works, when it helps, and when it becomes unnecessary complexity.
1. What Is Memoization?
Memoization is an optimization technique that caches the result of a computation so it can be reused when the relevant inputs haven't changed.
Imagine you have an expensive function:
function calculateTotalPrice(products: Product[]) {
console.log("Calculating total price...");
return products.reduce(
(total, product) => total + product.price,
0
);
}
Every time you call this function, it calculates the total again.
If the product list hasn't changed, repeating the same calculation may be unnecessary.
Memoization lets us reuse a previous result when the inputs are still valid.
Conceptually:
Inputs
|
v
Have relevant inputs changed?
|
+---- Yes ----> Recalculate and cache the result
|
+---- No -----> Reuse the cached result
React provides three commonly used memoization APIs:
useMemo โ caches a calculated value.
useCallback โ caches a function reference.
React.memo โ can skip a component render when its props are unchanged.
They solve different problems, so they shouldn't be treated as interchangeable.
2. useMemo: Cache an Expensive Calculation
Consider a product dashboard containing thousands of products.
You need to filter products by category and calculate which products match a search query.
function ProductList({
products,
search,
}: {
products: Product[];
search: string;
}) {
const filteredProducts = products.filter((product) =>
product.name.toLowerCase().includes(search.toLowerCase())
);
return (
<ul>
{filteredProducts.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
The filtering operation runs whenever ProductList renders.
That may be perfectly fine for a small list. But imagine the filtering logic is expensive and the component also renders because some unrelated state changes.
We can cache the calculated array with useMemo:
import { useMemo } from "react";
function ProductList({
products,
search,
}: {
products: Product[];
search: string;
}) {
const filteredProducts = useMemo(() => {
return products.filter((product) =>
product.name.toLowerCase().includes(search.toLowerCase())
);
}, [products, search]);
return (
<ul>
{filteredProducts.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
What changed?
React recalculates filteredProducts when products or search changes.
If the component renders again while both dependencies remain unchanged, React can reuse the previous result.
For example:
Initial render
โโโ products + search
โโโ Calculate filtered products
Unrelated state changes
โโโ products + search unchanged
โโโ Reuse cached result
Search changes
โโโ New search value
โโโ Calculate filtered products again
An important limitation
useMemo does not make every search update faster.
When the user types another character, search changes. The filtering calculation must run again to produce the correct results.
Memoization is most useful when a calculation is expensive and its dependencies remain unchanged across renders.
3. useCallback: Cache a Function Reference
Now let's look at useCallback.
Consider a parent component that passes an event handler to a child:
function ProductPage() {
const [count, setCount] = useState(0);
const handleAddToCart = (productId: number) => {
console.log("Added product:", productId);
};
return (
<>
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
<ProductCard onAddToCart={handleAddToCart} />
</>
);
}
Whenever ProductPage renders, the function expression creates a new function reference.
Even though the function's logic hasn't changed, the reference is different.
That matters if ProductCard is memoized and receives the handler as a prop.
const ProductCard = React.memo(function ProductCard({
onAddToCart,
}: {
onAddToCart: (productId: number) => void;
}) {
console.log("ProductCard rendered");
return (
<button onClick={() => onAddToCart(42)}>
Add to cart
</button>
);
});
By default, React.memo compares props. A new function reference means the handler prop is considered changed, so the child may render again.
We can stabilize the function reference with useCallback:
import { useCallback, useState } from "react";
function ProductPage() {
const [count, setCount] = useState(0);
const handleAddToCart = useCallback((productId: number) => {
console.log("Added product:", productId);
}, []);
return (
<>
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
<ProductCard onAddToCart={handleAddToCart} />
</>
);
}
Because the callback doesn't depend on any reactive values from the component, its dependency array is empty.
Now, when count changes, React can reuse the same callback reference. That allows the memoized ProductCard to skip rendering if its other props also remain unchanged.
What about dependencies?
Consider this callback:
const handleAddToCart = useCallback(() => {
setCartCount(cartCount + 1);
}, [cartCount]);
The callback depends on cartCount, so its reference changes whenever cartCount changes.
For state updates that depend on the previous state, a functional update can sometimes avoid that dependency:
const handleAddToCart = useCallback(() => {
setCartCount((currentCount) => currentCount + 1);
}, []);
This works because the updater receives the latest state value.
Important: useCallback caches a function reference; it doesn't make the function's execution inherently faster.
If you're not passing the function to a memoized child or using it in another dependency-sensitive context, useCallback may provide no meaningful benefit.
4. React.memo: Skip Unnecessary Component Renders
React.memo is different from the two hooks.
It wraps a component and lets React skip rendering it when its props are unchanged, subject to React's normal rendering behavior.
For example:
const ProductCard = React.memo(function ProductCard({
name,
price,
}: {
name: string;
price: number;
}) {
console.log("Rendering ProductCard:", name);
return (
<article>
<h3>{name}</h3>
<p>${price}</p>
</article>
);
});
Suppose the parent updates a separate counter:
function ProductPage() {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount((current) => current + 1)}>
Count: {count}
</button>
<ProductCard name="Keyboard" price={120} />
</>
);
}
The parent renders when the counter changes, but the ProductCard props remain the same.
React can skip rendering ProductCard.
However, React.memo is not an absolute rendering lock. The component can still render because of its own state changes, consumed context changes, or other React behavior.
A common mistake: unstable object props
Consider this example:
<ProductCard
product={{
id: 1,
name: "Keyboard",
price: 120,
}}
/>
A new object is created whenever the parent renders.
Even if the object contains the same data, its reference is different.
Consequently, a memoized child can still render.
This is why understanding referential equality matters when optimizing React applications.
Two objects can contain identical properties and still be different references:
const first = { name: "Keyboard" };
const second = { name: "Keyboard" };
console.log(first === second); // false
Memoization works best when the values that cross component boundaries have the stability needed for the optimization to be effective.
5. How These Three APIs Work Together
Let's put the concepts together.
Imagine a dashboard with:
- A product search field
- A product list
- A shopping cart counter
- Expensive product filtering
- Memoized product cards
A possible optimization strategy is:
ProductPage
|
โโโ useCallback
| โโโ Stabilize a handler passed to a memoized child
|
โโโ useMemo
| โโโ Cache filtered products when dependencies are unchanged
|
โโโ ProductList
|
โโโ React.memo
โโโ Skip rendering when props are unchanged
The APIs address different sources of repeated work:
| API | What it caches or optimizes | Useful when |
useMemo | A calculated value | A calculation is expensive and dependencies often stay unchanged |
useCallback | A function reference | Stable identity helps a memoized child or dependency-sensitive hook |
React.memo | A component's render work based on props | A component renders often with unchanged props and rendering is costly |
The key is not to use all three together automatically. Use only the optimization that addresses the measured bottleneck.
6. When Memoization Doesn't Help
Memoization has costs too.
It adds some combination of dependency tracking, comparisons, cached values, and code complexity.
Consider this example:
const fullName = useMemo(() => {
return `${firstName} ${lastName}`;
}, [firstName, lastName]);
Unless there is a specific reason to cache this value, the direct version is simpler:
const fullName = `${firstName} ${lastName}`;
Combining two strings is inexpensive. Memoizing it is unlikely to produce a measurable improvement.
Another example:
const handleClick = useCallback(() => {
console.log("Clicked");
}, []);
If this function is used only by a regular button in the same component, stabilizing its identity may not improve anything.
The lesson is simple:
Not every calculation is expensive, not every render is a problem, and not every new function reference matters.
7. React Compiler Changes the Conversation
Modern React projects may use the React Compiler, which can automatically memoize components, values, and functions when it can safely do so.
This means manually adding useMemo, useCallback, and React.memo everywhere is less necessary in compiler-enabled applications.
However, the compiler does not eliminate the need to understand performance.
It cannot make every expensive algorithm cheap, remove the cost of rendering thousands of unnecessary elements, or guarantee that a slow interaction will become fast.
You should still understand:
- What triggers a render
- How props and references change
- Which calculations are expensive
- When state should live closer to the components that use it
- How to measure actual rendering performance
If your project uses React Compiler, follow its guidance and avoid adding manual memoization without a reason. Manual memoization can still be useful in appropriate cases, but it shouldn't be automatic boilerplate.
8. Final Takeaway
Memoization is a tool, not a coding style, and the goal isn't to eliminate every render, the goal is to make the application responsive while keeping the code maintainable.
My preferred performance workflow remains:
Measure โ Identify the bottleneck โ Optimize โ Measure again.
Don't optimize because a component rendered, optimize because you've found work that is unnecessarily expensive.