React.memo & Avoiding Re-renders
React.memo & Avoiding Re-renders
Understanding when React re-renders is the foundation of React performance. Most "slow" React apps are actually rendering correctly — they just do more work than necessary. This lesson gives you the mental model to identify wasted renders and the tools to eliminate them.
When React Re-renders
A component re-renders when:
- Its own state changes
- Its parent re-renders (and passes new props)
- Context it consumes changes
Notice what's NOT on the list: the props themselves changing. By default, if a parent re-renders, ALL its children re-render, even if their props are identical.
function Counter() {
const [count, setCount] = useState(0);
console.log("Counter rendered");
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<StaticContent /> {/* This re-renders on every count change! */}
</div>
);
}
function StaticContent() {
console.log("StaticContent rendered"); // This logs on every click
return <div>I never change</div>;
}
Every click on the counter re-renders StaticContent, even though it has no props and never changes.
React.memo
Wrap a component in React.memo to make it only re-render when its props change:
const StaticContent = React.memo(function StaticContent() {
console.log("StaticContent rendered"); // Now only logs on mount
return <div>I never change</div>;
});
React does a shallow comparison of all props. If every prop passes === equality, the component skips the render.
When React.memo Doesn't Help
Shallow comparison means reference equality for objects and functions:
function Parent() {
const [count, setCount] = useState(0);
// ❌ New object on every render — memo won't help
const config = { theme: "dark", language: "en" };
// ❌ New function on every render — memo won't help
const handleClick = () => console.log("clicked");
return <MemoizedChild config={config} onClick={handleClick} />;
}
To make React.memo effective, you need stable prop references:
function Parent() {
const [count, setCount] = useState(0);
// ✅ Stable reference — only changes if theme/language change
const config = useMemo(() => ({ theme: "dark", language: "en" }), []);
// ✅ Stable function reference
const handleClick = useCallback(() => console.log("clicked"), []);
return <MemoizedChild config={config} onClick={handleClick} />;
}
const MemoizedChild = React.memo(function MemoizedChild({ config, onClick }) {
return <button onClick={onClick}>Theme: {config.theme}</button>;
});
The Stable setState Pattern
setState functions from useState are always stable — they never change identity between renders. You don't need useCallback to wrap functions that only call a setter:
function Parent() {
const [items, setItems] = useState<string[]>([]);
// ✅ setItems is stable — useCallback not needed here
function addItem(item: string) {
setItems(prev => [...prev, item]);
}
// But the function itself is recreated on every render
// If you pass it to a memo'd component, you need useCallback:
const stableAddItem = useCallback((item: string) => {
setItems(prev => [...prev, item]);
}, []); // setItems is stable, so empty deps is correct
return <MemoizedInput onAdd={stableAddItem} />;
}
State Colocation — The Better Optimization
Before reaching for React.memo, consider moving state closer to where it's used. When state lives at a higher level than necessary, it causes more components to re-render.
// ❌ Modal state in App causes everything to re-render on toggle
function App() {
const [modalOpen, setModalOpen] = useState(false);
return (
<div>
<ExpensiveList /> {/* Re-renders when modal opens/closes */}
<ExpensiveChart /> {/* Re-renders when modal opens/closes */}
<Modal isOpen={modalOpen} onClose={() => setModalOpen(false)} />
<button onClick={() => setModalOpen(true)}>Open</button>
</div>
);
}
// ✅ Extract the modal + trigger into its own component
function App() {
return (
<div>
<ExpensiveList /> {/* No longer affected by modal state */}
<ExpensiveChart /> {/* No longer affected by modal state */}
<DeleteConfirmation /> {/* Modal state lives here */}
</div>
);
}
function DeleteConfirmation() {
const [open, setOpen] = useState(false); // State scoped to this component
return (
<>
<button onClick={() => setOpen(true)}>Delete</button>
<Modal isOpen={open} onClose={() => setOpen(false)} />
</>
);
}
No memo, no useCallback — just better structure. This is usually the right fix first.
Lifting Content Up / Children as Props
Another structural optimization: pass components as children instead of rendering them directly. Children of a component only re-render if their own props change — the parent's state changes don't affect them:
// ❌ ExpensiveChild re-renders when Parent state changes
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<ExpensiveChild /> {/* Re-renders on count change */}
</div>
);
}
// ✅ Children passed from above don't re-render with Parent
function Parent({ children }: { children: React.ReactNode }) {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
{children} {/* Not affected by count state */}
</div>
);
}
// App creates the child, Parent just slots it in
function App() {
return (
<Parent>
<ExpensiveChild /> {/* Only re-renders if App re-renders */}
</Parent>
);
}
Lazy Loading Components
For large components not needed on initial load, use React.lazy:
import { lazy, Suspense } from "react";
// The import is deferred until this component is first rendered
const RichEditor = lazy(() => import("./RichEditor"));
const VideoPlayer = lazy(() => import("./VideoPlayer"));
function CourseEditor() {
const [activeTab, setActiveTab] = useState("content");
return (
<div>
<TabBar active={activeTab} onChange={setActiveTab} />
<Suspense fallback={<div>Loading editor...</div>}>
{activeTab === "content" && <RichEditor />}
{activeTab === "preview" && <VideoPlayer />}
</Suspense>
</div>
);
}
RichEditor and VideoPlayer are only downloaded when the user clicks that tab. The initial bundle is smaller. Page loads faster.
Performance Checklist
Before reaching for memoization:
- Check if it's actually slow — open React DevTools Profiler and measure
- Colocate state — move state down to where it's needed
- Use children as props — structural fix, no hooks needed
- Then add React.memo +
useMemo+useCallbackif specific components are measured to be slow
Premature optimization makes code harder to read. Measure, then optimize the things that actually matter.
Next lesson: Next.js 15 architecture overview — the full picture of how Next.js works.
Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises