Why React.memo Breaks When You Pass a Function (and How to Fix It)
React Performance Optimization Pitfall: I Thought memo Was Enough, Until I Passed a Function
Opening
Let's start with a piece of code. Can you tell what it's trying to optimize?
function App() {
const [count, setCount] = useState(0);
const [name, setName] = useState('少林队');
return (
<>
<button onClick={() => setCount(count + 1)}>
Click count {count}
</button>
<button onClick={() => setName('wxh')}>
Change name
</button>
<RegularChild name={name} />
<MemoChild name={name} />
</>
);
}
Console output: Every time you click "Click count":
App render
RegularChild render
← MemoChild didn't render! memo is working
It looks like memo is perfect. MemoChild is wrapped in memo, count changed but name didn't, so it skipped rendering.
But wait—what if I add one line of code, passing a callback function to MemoChild?
<MemoChild name={name} onDone={() => console.log('done')} />
Guess again. When you click "Click count", what will the console output?
App render
RegularChild render
MemoChild render ← Didn't we say it would skip???
memo has failed. Just because you passed one extra arrow function—something you've written hundreds of times. Your memo, your optimization, your performance optimization articles—all wasted.
This article solves that problem.
First, Understand memo's Judgment Logic
The essence of React.memo is shallow comparison. Every time the parent component renders, React does an === comparison between the new props and the old props. Only if the props have changed does the child component render.
For a primitive value like name="少林队", "少林队" === "少林队" is true, so rendering is skipped. Everything goes smoothly.
But the problem is—not all values can be compared with ===.
graph TD
A["Parent component renders"] --> B["Generates new props object"]
B --> C{"memo shallow comparison"}
C -->|"All props === old values"| D["Skip child component render"]
C -->|"Any prop !== old value"| E["Child component re-renders"]
F["Primitive value: '少林队' === '少林队'"] --> C
G["Object/Function: {} !== {}"] --> C
H["Arrow function: () => ... !== previous () => ..."] --> C
Functions are objects, and objects are references. Every time rendering happens, () => console.log('done') is a completely new function reference—even if it does exactly the same thing. Shallow comparison sees it as "different" and allows rendering to proceed.
memochecks whether the reference is the same, not whether the content is the same.
Create a "memo Failure" Scenario Yourself
Building on the original code, add a scenario: passing a callback.
import { useState, memo } from 'react';
const MemoChild = memo(({ name, onDone }) => {
console.log('MemoChild render');
return (
<div>
Hello {name}
<button onClick={onDone}>Done</button>
</div>
);
});
function App() {
const [count, setCount] = useState(0);
const [name, setName] = useState('少林队');
// ⚠️ Every time App renders, a brand new function is generated here
const handleDone = () => {
console.log('done:', name);
};
return (
<>
<button onClick={() => setCount(count + 1)}>
Click count {count}
</button>
<MemoChild name={name} onDone={handleDone} />
</>
);
}
Result:
Click "Click count":
App render
MemoChild render ← It rendered again! count has nothing to do with MemoChild
name didn't change. But onDone did. Not the content—the reference changed. Every time App renders, JavaScript re-executes const handleDone = () => {...}, generating a completely new object in memory. To memo, this is a "new prop", so it allows rendering.
Solution: Stabilize the Reference
What useCallback does is simple: unless its dependencies change, it always returns the same function reference.
import { useState, memo, useCallback } from 'react';
function App() {
const [count, setCount] = useState(0);
const [name, setName] = useState('少林队');
// 🔑 Key: if name doesn't change, handleDone's reference doesn't change
const handleDone = useCallback(() => {
console.log('done:', name);
}, [name]); // ← name unchanged, handleDone is always the same function object
return (
<>
<button onClick={() => setCount(count + 1)}>
Click count {count}
</button>
<MemoChild name={name} onDone={handleDone} />
</>
);
}
Now click "Click count" again:
App render
← MemoChild is quiet!
Because name didn't change → handleDone reference didn't change → memo shallow comparison passes → rendering is skipped.
useCallbackdoesn't "make the function faster"; it "makes the same function always use the same reference."
Controlled Experiment: Visualizing memo's Failure and Fix
Let's use an experiment to compare the behavior of both approaches side by side:
// Experiment: Compare the impact of two approaches on MemoChild's render count
import { useState, memo, useCallback } from 'react';
let renderCountGood = 0;
let renderCountBad = 0;
const GoodChild = memo(({ onClick }) => {
renderCountGood++;
return <button onClick={onClick}>Good</button>;
});
const BadChild = memo(({ onClick }) => {
renderCountBad++;
return <button onClick={onClick}>Bad</button>;
});
export default function Experiment() {
const [count, setCount] = useState(0);
// ✅ Stable reference
const stableFn = useCallback(() => {}, []);
// ❌ New reference every render cycle
const unstableFn = () => {};
return (
<div>
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
<GoodChild onClick={stableFn} />
<BadChild onClick={unstableFn} />
<p>GoodChild renders: {renderCountGood}</p>
<p>BadChild renders: {renderCountBad}</p>
</div>
);
}
Actual output after clicking the Count button 5 times:
GoodChild renders: 1 ← Only the initial render
BadChild renders: 6 ← Initial + 5 re-renders following the parent
Same memo, same structure, the only difference is whether the passed function used useCallback. A 6x difference. With dozens of components and hundreds of renders in your application—the accumulation leads to lag.
But—Don't Wrap Every Function in useCallback
You might think: "Then I'll just use useCallback for all my functions, as a preventive measure."
Don't. The React team's design philosophy is—optimization has a cost, don't pay for what you don't need. What is the cost of useCallback?
- Memory cost: React needs to store the dependency array and the cached function reference additionally
- Comparison cost: Every render, it must check whether each item in the dependency array has changed
- Code complexity: An extra layer of
useCallbackwrapping adds an extra layer of mental burden
If the child component is not wrapped in memo, passing useCallback into it yields no benefit—the child component will render every time anyway. React's diffing algorithm is already fast; don't "optimize" where there is no bottleneck.
The value of
useCallbackonly exists within thememo+useCallbackpair. Using either one alone is just wasting lines of code.
graph TD
A["Child component uses memo"] -->|Yes| B["Prop is a function"]
A -->|No| C["No need for useCallback"]
B -->|Yes| D["Function has dependencies"]
B -->|No| C
D -->|Yes| E["useCallback(fn, [deps])"]
D -->|No| F["useCallback(fn, [])"]
Back to Design Philosophy: Why Doesn't React Auto-memo?
Now that you truly understand the memo + useCallback combination—you won't think: "Is there a babel plugin that automatically wraps all my components in memo and all functions in useCallback?"
The React core team did discuss this approach. React Forget (later React Compiler) had this idea as an early direction. But why is the default behavior not to optimize?
Because JavaScript functions and objects are inherently "created anew each time." React chooses not to do extra work unless you explicitly need it:
- React doesn't assume your performance needs: Most applications render fast enough; the overhead of memo outweighs the benefit
- Shallow comparison itself has a cost: Every prop must be
===checked once; if there are many props, it can be slower than re-rendering - Correctness takes priority over performance: React guarantees the UI is correct. Optimization is something you do "after correctness"
The spirit of React core member Dan Abramov's original words is: "Don't optimize until you've measured a performance problem." Tools like React DevTools Profiler and React Scan are there to tell you "where optimization is needed"—not for guessing in the dark.
Conclusion
Back to the opening scenario—you added memo, thought the optimization was done, and then passing an arrow function ruined everything. This isn't your fault; it's the "design friction" between JavaScript's reference semantics and React's rendering model.
Remember one sentence: For memo's optimization to hold, every reference-type prop passed to it must have a stable reference. useCallback stabilizes function references, useMemo stabilizes object references. The three are a set; missing one creates a leak.
memo closes the door for you; useCallback / useMemo are responsible for locking it. Just closing the door without locking it—a gust of wind will blow it open.
Next time you write memo, scan the props you're passing to it—if there are functions or objects inside, ask yourself: "Is this reference the same this render cycle as the last one?"
How many memos in your project are truly "doors closed and locked"? Go scan them and tell me in the comments what you found.
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
I've learned a lot from this.