1. đ Introduction
React is fast by default, but as applications grow â more components, deeper trees, larger lists â performance problems inevitably creep in. This tutorial covers the full toolkit for diagnosing and fixing them: rendering optimization, memoization, code splitting, list virtualization, and profiling, along with common pitfalls to avoid.
Information
2. đ Understanding React Performance
React's performance model centers on the render phase (computing what the UI should look like) and the commit phase (applying those changes to the real DOM). Most performance problems come from rendering too often or rendering too much at once.
3. đ§ Performance Bottlenecks
Common sources of slowness in React apps include:
- Unnecessary re-renders cascading through a large component tree
- Expensive calculations repeated on every render
- Large lists rendering thousands of DOM nodes at once
- Oversized JS bundles that slow initial page load
- Unoptimized images and heavy assets
4. đ¯ Rendering Optimization
The single biggest lever for React performance is reducing how often, and how much, gets re-rendered. This involves both preventing unnecessary re-renders and making necessary ones cheaper.
5. đ Re-rendering
A component re-renders whenever its state changes, its props change, or its parent re-renders â even if the new props are identical in value. Understanding this cascade is the foundation of all optimization work.
Note
6. đĄī¸ Preventing Unnecessary Re-renders
React provides three core tools to prevent wasted re-renders: React.memo for components, useMemo for computed values, and useCallback for function references. Each is covered in detail below.
7. đ§ React.memo
React.memo wraps a component so it only re-renders when its props actually change (via a shallow comparison), skipping re-renders triggered purely by a parent update.
MemoExample.jsx
const ProductCard = React.memo(function ProductCard({ product }) {
console.log("Rendering", product.name);
return <div>{product.name} - {product.price{"}"}</div>;
});Caution
8. đ§Ž useMemo
useMemo caches the result of an expensive calculation, recomputing it only when its dependencies change.
UseMemoExample.jsx
function ProductList({ products, filterText }) {
const filtered = useMemo(
() => products.filter((p) => p.name.includes(filterText)),
[products, filterText]
);
return <ul>{filtered.map((p) => <li key={p.id}>{p.name}</li>)}</ul>;
}Warning
9. đ useCallback
useCallback caches a function reference across renders, which matters most when that function is passed as a prop to a React.memo-wrapped child.
UseCallbackExample.jsx
function ParentComponent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log("Clicked!");
}, []);
return <MemoizedButton onClick={handleClick} />;
}Best Practice
10. đ¤ Lazy Loading
Lazy loading defers downloading and executing a component's code until it's actually needed â for example, a modal or settings panel the user might never open.
11. đĻĨ React.lazy
React.lazy wraps a dynamic import() call, turning a component into one that loads its code on demand.
ReactLazyExample.jsx
const SettingsPanel = React.lazy(() => import("./SettingsPanel"));12. â¸ī¸ Suspense
<Suspense> displays a fallback UI while a lazy-loaded component's code is still downloading.
SuspenseExample.jsx
function App() {
return (
<Suspense fallback={<Spinner />}>
<SettingsPanel />
</Suspense>
);
}13. âī¸ Code Splitting
Code splitting breaks a single large JS bundle into smaller chunks, loaded only when needed â most commonly split by route or by heavy, rarely-used features.
RouteSplitting.jsx
const Dashboard = React.lazy(() => import("./pages/Dashboard"));
const Settings = React.lazy(() => import("./pages/Settings"));
<Routes>
<Route path="/dashboard" element={<Suspense fallback={<Spinner />}><Dashboard /></Suspense>} />
<Route path="/settings" element={<Suspense fallback={<Spinner />}><Settings /></Suspense>} />
</Routes>14. đĻ Dynamic Imports
Beyond React.lazy, plain dynamic import() can defer loading non-component code too â like a heavy charting library only needed after a button click.
DynamicImport.jsx
async function exportToPdf(data) {
const { generatePdf } = await import("./pdfGenerator");
return generatePdf(data);
}15. đ§Š Component Splitting
Breaking a large component into smaller ones isn't just for readability â it also isolates re-renders. A state update in a small subcomponent no longer forces the entire parent tree to re-render.
ComponentSplitting.jsx
// Before: one state change re-renders the whole page
function Page() {
const [query, setQuery] = useState("");
return (
<div>
<SearchInput query={query} onChange={setQuery} />
<ExpensiveChart /> {/* re-renders on every keystroke, unnecessarily */}
</div>
);
}
// After: isolate the frequently-changing piece
function Page() {
return (
<div>
<SearchBox />
<ExpensiveChart />
</div>
);
}16. đŧī¸ Image Optimization
Images are often the heaviest assets on a page. Use appropriately sized images, modern formats (WebP, AVIF), and the native loading="lazy" attribute to defer offscreen images.
LazyImage.jsx
<img src="/hero.webp" alt="Product hero" loading="lazy" width={800} height={400} />Tip
17. đ List Virtualization
Virtualization renders only the visible rows of a long list, plus a small buffer â instead of mounting thousands of DOM nodes at once.
VirtualizedList.jsx
import { FixedSizeList } from "react-window";
function ProductList({ products }) {
const Row = ({ index, style }) => (
<div style={style}>{products[index].name}</div>
);
return (
<FixedSizeList height={600} itemCount={products.length} itemSize={50} width="100%">
{Row}
</FixedSizeList>
);
}18. đĒ Windowing
"Windowing" is another name for the same technique as virtualization â only a small window of rows exists in the DOM at any time, with the rest tracked purely in memory. Libraries like react-window and react-virtualized implement this pattern.
19. âąī¸ Debouncing
Debouncing delays running a function until the user has stopped triggering it for a set period â ideal for search-as-you-type inputs that shouldn't fire a request on every keystroke.
Debounce.jsx
function useDebouncedValue(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timeout = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timeout);
}, [value, delay]);
return debounced;
}20. đĻ Throttling
Throttling limits a function to running at most once per interval, regardless of how many times it's triggered â well-suited to high-frequency events like scroll or resize.
Throttle.jsx
function throttle(fn, limit) {
let waiting = false;
return (...args) => {
if (!waiting) {
fn(...args);
waiting = true;
setTimeout(() => (waiting = false), limit);
}
};
}Reference
21. đī¸ Memoization Strategies
Choosing what to memoize is as important as knowing how. A good rule of thumb: memoize expensive computations and stable references passed to memo-wrapped children â not everything, everywhere.
| Tool | Memoizes | Use when |
|---|---|---|
| React.memo | A component's render output | Component re-renders often with unchanged props |
| useMemo | A computed value | Calculation is expensive (sorting, filtering large data) |
| useCallback | A function reference | Function is passed to a memo-wrapped child |
22. đī¸ State Optimization
Keeping state as local as possible â close to where it's used â limits how much of the tree re-renders when it changes. Lifting state up unnecessarily is a common source of avoidable re-renders.
LocalState.jsx
// Better: dropdown's open/closed state stays local
function Dropdown() {
const [isOpen, setIsOpen] = useState(false);
// only this small component re-renders on toggle
}23. đ Context Optimization
Every component consuming a Context re-renders whenever any part of that context's value changes â even if the consumer only cares about one field. Split large contexts into smaller, focused ones to limit this blast radius.
SplitContext.jsx
// Instead of one large AppContext with { user, theme, cart }...
const UserContext = createContext(null);
const ThemeContext = createContext(null);
const CartContext = createContext(null);
// Components only re-render when the specific context they use changes24. đĻ Bundle Size Optimization
A smaller JS bundle means faster downloads and faster parsing, directly improving initial load time. Key techniques: code splitting (Section 13), tree shaking (Section 25), and auditing dependencies for lighter alternatives.
25. đŗ Tree Shaking
Tree shaking is a build-tool optimization that removes unused exports from the final bundle. It works best with ESM import/export syntax, since bundlers can statically analyze what's actually used.
TreeShakingExample.jsx
// Good: only imports what's used, tree-shakeable
import { debounce } from "lodash-es";
// Avoid: imports the entire library
import _ from "lodash";26. đī¸ Production Builds
Always measure performance using a production build, never the development server â React's development mode includes extra warnings and checks that significantly slow down rendering.
terminal
npm run build
npm run previewImportant
27. đ React Profiler
The React DevTools Profiler records how long each component takes to render and why it re-rendered, making it the primary tool for diagnosing real bottlenecks rather than guessing.
28. đŦ Profiling Components
React also exposes a <Profiler> component for programmatic measurement, useful for automated performance regression tracking.
ProfilerComponent.jsx
import { Profiler } from "react";
function onRender(id, phase, actualDuration) {
console.log(`${id} (${phase}) took ${actualDuration}ms`);
}
<Profiler id="ProductList" onRender={onRender}>
<ProductList products={products} />
</Profiler>29. đ Performance Monitoring
Beyond local profiling, production monitoring tools (like RUM services or Web Vitals reporting) track real-world metrics â Largest Contentful Paint, Interaction to Next Paint, and similar â across your actual user base.
30. đ§ Memory Optimization
Beyond render speed, React apps can suffer from memory growth over time â often from leaked subscriptions, timers, or event listeners that never get cleaned up.
31. đ° Avoiding Memory Leaks
Always clean up subscriptions, timers, and listeners in a useEffect cleanup function, or they'll keep running â and holding references â long after a component unmounts.
CleanupEffect.jsx
useEffect(() => {
const handleResize = () => console.log(window.innerWidth);
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);32. đ ī¸ Optimizing Effects
Keep useEffect dependency arrays accurate and minimal. Effects that run more often than necessary â or that omit needed dependencies â are a frequent, subtle source of wasted work and bugs.
Caution
33. đąī¸ Optimizing Event Handlers
For frequent events like onScroll or onMouseMove, combine throttling/debouncing (Sections 19â20) with useCallback to avoid both excessive handler invocations and unnecessary child re-renders.
34. đ Optimizing Lists
Beyond virtualization, always give list items a stable, unique key â ideally an ID, never an array index for lists that reorder â so React can correctly match and reuse existing DOM nodes.
StableKeys.jsx
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}35. đ Optimizing Forms
Large, highly controlled forms can re-render the entire form on every keystroke. Splitting fields into isolated subcomponents, or using an uncontrolled approach via a library like React Hook Form, keeps typing responsive even in big forms.
36. đĢ Performance Anti-Patterns
- Creating new object or array literals inline as props (style={{...}}) on every render
- Defining components inside another component's body, causing them to remount on every render
- Wrapping every single component in React.memo "just in case," adding overhead without benefit
- Storing large, frequently-changing values in a single top-level context
AntiPatternExample.jsx
// Bad: InnerComponent is redefined and remounted every render
function Parent() {
function InnerComponent() { return <div>Hi</div>; }
return <InnerComponent />;
}
// Good: define components outside the render function
function InnerComponent() { return <div>Hi</div>; }
function Parent() {
return <InnerComponent />;
}37. đˇ TypeScript Considerations
TypeScript itself doesn't affect runtime performance, but strict typing on memoized values and callback signatures helps catch a common bug: accidentally passing a differently-shaped prop that defeats React.memo's comparison.
TypedMemo.tsx
interface ProductCardProps {
product: { id: string; name: string; price: number };
}
const ProductCard = React.memo(function ProductCard({ product }: ProductCardProps) {
return <div>{product.name}</div>;
});38. đ Best Practices
- Always measure first with the Profiler before optimizing anything
- Keep state as local as possible to limit re-render scope
- Virtualize any list rendering more than a few hundred rows
- Code-split by route and by rarely-used, heavy features
- Use React.memo, useMemo, and useCallback deliberately â not by default everywhere
39. â ī¸ Common Mistakes
- Optimizing based on intuition rather than actual profiler data
- Adding useMemo/useCallback to trivial values, adding overhead with no real gain
- Forgetting that React.memo does nothing if new object/function props are created every render
- Rendering huge lists without virtualization
- Not cleaning up effects, leading to slow memory growth over a long session
Danger
40. đŦ Frequently Asked Questions
Should I wrap every component in React.memo?
No â React.memo adds a comparison cost on every render. Reserve it for components that render often with genuinely unchanged props, especially ones with expensive render logic.
How do I know if my app actually has a performance problem?
Use the React DevTools Profiler on a production build to look for components with unexpectedly long or frequent render times before assuming optimization is needed at all.
Is virtualization always necessary for long lists?
Not always â lists of a few dozen items are usually fine unmodified. Virtualization becomes valuable once lists reach hundreds or thousands of rows.