Performance & Best Practices
Checking access...
React is fast by default, but as your app grows, you’ll encounter performance challenges. This page covers the tools and patterns for keeping your React app fast.
When to Optimise
Tip
Profile first, optimise second. Don’t add optimisations prematurely — they add complexity. Use React DevTools Profiler to identify real bottlenecks before reaching for useMemo or React.memo.
React.memo
React.memo is a higher-order component that prevents re-renders when props haven’t changed (by shallow comparison):
import { memo } from "react";
const ExpensiveChart = memo(function ExpensiveChart({ data, title }) { console.log("Rendering chart..."); return ( <div> <h3>{title}</h3> <Chart data={data} /> </div> );});
// Parent — won't re-render ExpensiveChart unless data or title reference changesfunction Dashboard({ chartData }) { const [filter, setFilter] = useState("all");
return ( <div> <button onClick={() => setFilter("active")}>Filter</button> <ExpensiveChart data={chartData} title="Revenue" /> </div> );}When to Use React.memo
| Use It | Don’t Use It |
|---|---|
| Pure presentational components | Components with cheap renders |
| Components that re-render often with same props | Components with children that change |
| Heavy render logic (charts, tables) | Simple elements (divs, spans) |
| Large lists | Components with dynamic props every render |
Custom Comparison Function
By default, React.memo does a shallow comparison. For custom logic, pass a second argument:
const UserCard = memo( ({ user }) => <div>{user.name}</div>, (prevProps, nextProps) => { // Only re-render if user.id changed return prevProps.user.id === nextProps.user.id; });useMemo
Memoizes the result of a computation. Only recalculates when dependencies change:
import { useMemo } from "react";
function Analytics({ transactions, filter }) { // ❌ Expensive calculation on every render const filteredData = transactions.filter((t) => t.type === filter); const totals = calculateTotals(filteredData); // expensive
// ✅ Only recalculates when transactions or filter changes const memoisedTotals = useMemo( () => calculateTotals(transactions.filter((t) => t.type === filter)), [transactions, filter] );
return <Chart data={memoisedTotals} />;}When to Use useMemo
- Expensive computations — filtering large arrays, complex math, data transformation
- Stabilising references — objects passed to memoised children
- Avoiding expensive recalculations — but only if the calculation actually costs something
// ✅ Good: expensive computationconst sortedItems = useMemo( () => [...items].sort((a, b) => expensiveCompare(a, b)), [items]);
// ✅ Good: stabilise object reference for memoised childconst config = useMemo( () => ({ theme: "dark", fontSize: 14 }), []);
// ❌ Unnecessary: simple operationsconst fullName = useMemo(() => `${first} ${last}`, [first, last]);useCallback
Memoizes a function reference. Prevents child components from re-rendering when a callback is passed as prop:
import { useCallback } from "react";
function ProductList({ products, onAddToCart }) { // ❌ New function every render → memoised children re-render const handleClick = (id) => { onAddToCart(id); };
// ✅ Same function reference when deps don't change const handleClickMemo = useCallback( (id) => onAddToCart(id), [onAddToCart] );
return products.map((p) => ( <ProductCard key={p.id} product={p} onBuy={handleClickMemo} /> ));}When to Use useCallback
- Passing callbacks to
React.memochildren - Functions in
useEffectdependency arrays - Custom hooks that return functions
function useDebouncedSearch() { const [query, setQuery] = useState("");
// Stable function reference for the custom hook API const search = useCallback((q) => { setQuery(q); }, []);
return { query, search };}Code Splitting with Lazy + Suspense
Split your bundle so users only download code they need:
import { lazy, Suspense } from "react";
// Dynamic import — webpack/vite creates a separate chunkconst Dashboard = lazy(() => import("./pages/Dashboard"));const Settings = lazy(() => import("./pages/Settings"));const Analytics = lazy(() => import("./pages/Analytics"));
function App() { return ( <Suspense fallback={<PageLoader />}> <Routes> <Route path="/dashboard" element={<Dashboard />} /> <Route path="/settings" element={<Settings />} /> <Route path="/analytics" element={<Analytics />} /> </Routes> </Suspense> );}
function PageLoader() { return <div className="page-loader">Loading page...</div>;}Component-Level Splitting
// For large components used conditionallyfunction Editor({ isOpen }) { return ( <Suspense fallback={<EditorSkeleton />}> {isOpen ? <RichEditor /> : null} </Suspense> );}
const RichEditor = lazy(() => import("./RichEditor"));Performance Profiling
React DevTools Profiler
- Open React DevTools → Profiler tab
- Click the record button (⚫)
- Interact with your app
- Stop recording
Look for:
- Flamegraph: Wide bars = slow components
- Ranked: Components that re-rendered, sorted by time
- Causes: Why a component re-rendered (props changed, state changed, context changed)
Identifying Unnecessary Re-renders
// Add a console.log to see when components renderfunction MyComponent() { console.log(`Rendering: MyComponent`); return <div>...</div>;}Common Performance Issues
- Inline objects/arrays in props
// ❌ New object every render<MemoisedChild config={{ theme: "dark" }} />
// ✅ Stable referenceconst config = useMemo(() => ({ theme: "dark" }), []);<MemoisedChild config={config} />- Anonymous functions in JSX
// ❌ New function every render<button onClick={() => handleClick(id)} />
// ✅ Stable functionconst handleClick = useCallback(() => clicked(id), [id]);<button onClick={handleClick} />- Prop spreading unnecessary data
// ❌ Passes entire user object (any change causes re-render)<Profile {...user} />
// ✅ Pass only what's needed<Profile name={user.name} avatar={user.avatar} />- Lifting state too high
// ❌ State in App causes entire tree to re-renderfunction App() { const [count, setCount] = useState(0); return ( <div> <Counter count={count} setCount={setCount} /> <ExpensiveSidebar /> {/* Re-renders unnecessarily */} </div> );}
// ✅ Keep state close to where it's usedfunction App() { return ( <div> <Counter /> <ExpensiveSidebar /> </div> );}Virtualisation
For long lists, only render what’s visible. Use libraries like react-window or react-virtuoso:
npm install react-windowimport { FixedSizeList } from "react-window";
function VirtualList({ items }) { const Row = ({ index, style }) => ( <div style={style}> {items[index].name} </div> );
return ( <FixedSizeList height={500} itemCount={items.length} itemSize={50} width="100%" > {Row} </FixedSizeList> );}Bundle Analysis
Check what’s in your production bundle:
# Vitenpm run build && npx vite-bundle-analyzer
# Create React Appnpm run build && npx source-map-explorer build/static/js/*.jsReducing Bundle Size
// ❌ Importing entire libraryimport { debounce } from "lodash";
// ✅ Import only what you needimport debounce from "lodash/debounce";
// ❌ Large library when a simple function would doimport { formatDistance } from "date-fns";
// ✅ Small alternativesconst timeAgo = (date) => { const seconds = Math.floor((Date.now() - new Date(date)) / 1000); // simple implementation};Quick Reference
| Tool | Purpose | When |
|---|---|---|
React.memo | Prevent re-render on same props | Pure components |
useMemo | Memoize computed values | Expensive calculations |
useCallback | Memoize function references | Callbacks passed to children |
lazy + Suspense | Code splitting | Large/rarely-used components |
react-window | Virtualisation | 1000+ item lists |
| Profiler | Find bottlenecks | Before optimising |
Practice Exercises
Profile and optimise: Build a component that renders a list of 500 items with a filter input. Use the React Profiler to measure render times. Apply
useMemofor the filtered list andReact.memofor list items. Measure the improvement.Code splitting: Create an app with a heavy component (e.g., a chart library) that’s only shown when a button is clicked. Use
lazyandSuspenseto split it into a separate chunk. Verify the separate chunk is created in the build output.Virtualised list: Generate an array of 10000 items and render them using
react-window. Compare memory usage and render time against a regularmaprendering.