Unnecessary effect re-runs are one of the most common React performance issues. They manifest as duplicate API calls, flickering UIs, and infinite render loops. Understanding why they happen, and when to reach for each memoization tool, is essential for production-grade React.
The Root Cause
React compares effect dependencies using Object.is (strict reference equality). Functions are recreated on every render by default, so their reference changes every time, even if the function body is identical.
function MyComponent({ userId }: { userId: string }) {
const [data, setData] = useState(null);
// ❌ Problem: fetchUser is a new function instance on every render
const fetchUser = () => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setData);
};
// The effect re-runs on EVERY render because fetchUser changes every render
useEffect(() => {
fetchUser();
}, [fetchUser]);
return <div>{data?.name}</div>;
}
Fix 1: useCallback: Stabilize Function References
useCallback returns a memoized version of the function that only changes when its declared dependencies change:
function MyComponent({ userId }: { userId: string }) {
const [data, setData] = useState(null);
// ✅ Only recreated when userId changes
const fetchUser = useCallback(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setData);
}, [userId]);
useEffect(() => {
fetchUser();
}, [fetchUser]); // Stable unless userId changes
return <div>{data?.name}</div>;
}
Fix 2: Move the Function Inside the Effect
If the function is only used inside one effect, move it inside. No dependency array entry needed:
useEffect(() => {
// Function lives here, no memoization required
async function fetchUser() {
const res = await fetch(`/api/users/${userId}`);
const json = await res.json();
setData(json);
}
fetchUser();
}, [userId]); // userId is the only real dependency
This is often the cleanest solution and preferred by the React team for effects with a single-use function.
Fix 3: useMemo: Memoize Expensive Derived Values
useCallback is syntactic sugar for useMemo returning a function. Use useMemo directly for computed values:
function ProductList({ products, searchTerm }: Props) {
// ❌ Recomputes on every render
const filtered = products.filter(p =>
p.name.toLowerCase().includes(searchTerm.toLowerCase())
);
// ✅ Only recomputes when products or searchTerm changes
const filtered = useMemo(() =>
products.filter(p =>
p.name.toLowerCase().includes(searchTerm.toLowerCase())
),
[products, searchTerm]
);
return <ul>{filtered.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}
Fix 4: React.memo: Skip Child Re-renders
Wrap child components that receive stable props to prevent re-renders when the parent re-renders:
const UserCard = React.memo(({ user, onSelect }: UserCardProps) => {
console.log('UserCard render'); // Should only log when user or onSelect changes
return (
<div onClick={() => onSelect(user.id)}>
{user.name}
</div>
);
});
function ParentComponent({ users }: { users: User[] }) {
const [selected, setSelected] = useState<string | null>(null);
// ✅ Stable: memoize the callback passed to memo'd child
const handleSelect = useCallback((id: string) => {
setSelected(id);
}, []); // No deps, setSelected is always stable
return (
<ul>
{users.map(user => (
))}
</ul>
);
}
React.memo + useCallback is the canonical pattern for preventing child re-renders.
Fix 5: Custom Hooks: Return Stable References
When building custom hooks, always memoize the values you return:
function useUserData(userId: string) {
const [data, setData] = useState<User | null>(null);
const [loading, setLoading] = useState(false);
const refetch = useCallback(async () => {
setLoading(true);
try {
const res = await fetch(`/api/users/${userId}`);
setData(await res.json());
} finally {
setLoading(false);
}
}, [userId]);
useEffect(() => {
refetch();
}, [refetch]);
return { data, loading, refetch }; // refetch is stable per userId
}
React 19: The Compiler Handles Most of This Automatically
React 19 ships the React Compiler, a Babel/SWC plugin that analyzes your components and automatically inserts memoization where needed. It eliminates most manual useCallback and useMemo calls.
Enable it in next.config.ts (Next.js 15+):
// next.config.ts
const nextConfig = {
experimental: {
reactCompiler: true,
},
};
export default nextConfig;
With the compiler enabled, the following code is automatically optimized without any useCallback:
// React Compiler makes this safe, no manual memoization needed
function AutoOptimized({ userId }: { userId: string }) {
const [data, setData] = useState(null);
const fetchUser = () => {
fetch(`/api/users/${userId}`).then(r => r.json()).then(setData);
};
useEffect(() => { fetchUser(); }, [userId]);
return <div>{data?.name}</div>;
}
The compiler is opt-in for now. Until you enable it, the manual patterns above remain necessary.
Profiling: Find What’s Actually Slow
- Install React DevTools (Chrome/Firefox extension)
- Open Profiler tab → click Record → interact with your app → Stop
- Click a component bar → check "Why did this render?"
- Look for: "Props changed" with identical-looking values → unstable references
// Quick diagnostic: log whenever effect runs
useEffect(() => {
console.log('Effect fired. userId:', userId);
// If this logs on every keystroke unrelated to userId,
// you have an unstable reference in the dependency array
}, [userId]);
Decision Chart
| Situation | Tool |
|---|---|
| Function in useEffect deps |
useCallback or move function inside effect |
| Expensive derived value | useMemo |
| Child component re-renders too often |
React.memo + useCallback for prop callbacks |
| Custom hook returning functions |
useCallback on returned functions |
| React 19 + React Compiler enabled | Nothing, compiler handles it |
What NOT to Memoize
- Simple inline event handlers (
onClick={() => setCount(c => c + 1)}) - Functions that aren’t in dependency arrays or passed to memoized children
- Everything preemptively: measure first, optimize second
The rule: memoize to solve a specific, measured problem, not as a default coding style.
Top comments (0)