Skip to content

React Performance

React is fast by default, but large apps can develop performance issues. The main cause: unnecessary re-renders. Understand what triggers re-renders before optimising.

A component re-renders when:

  1. Its own state changes
  2. Its parent re-renders (even if props haven’t changed)
  3. A context it consumes changes

Prevents re-render when parent re-renders but props haven’t changed:

const UserCard = React.memo(function UserCard({ user }: { user: User }) {
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
});

React.memo does a shallow comparison of props. Objects and arrays that are recreated on each render will still trigger re-renders — combine with useMemo and useCallback.

Memoises the result of an expensive computation:

function ProductList({ products, filter }: Props) {
const filteredProducts = useMemo(
() => products.filter(p => p.category === filter),
[products, filter]
);
return <ul>{filteredProducts.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}

Don’t reach for useMemo reflexively — profiling first is better.

Memoises a function reference so it doesn’t change between renders:

function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
return <Child onClick={handleClick} />;
}

Split your bundle so users only download the code they need:

import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
function App() {
return (
<Suspense fallback={<Spinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}

Context value changing triggers a re-render in every consumer.

Memoize the value:

function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');
const toggleTheme = useCallback(
() => setTheme(t => (t === 'light' ? 'dark' : 'light')),
[]
);
const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

Split contexts by update frequency:

const UserActionsContext = createContext({ logout: () => {} });
const UserDataContext = createContext<User | null>(null);

Render only visible rows for very long lists:

Terminal window
npm install @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
});
return (
<div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map(row => (
<div
key={row.index}
style={{ transform: `translateY(${row.start}px)`, position: 'absolute' }}
>
{items[row.index].name}
</div>
))}
</div>
</div>
);
}
  1. Install React DevTools browser extension
  2. Open DevTools → Profiler tab
  3. Click record, interact with the app, stop recording
  4. Inspect flame graph to see which components re-render most
  • Profile before optimising — measure, don’t guess
  • React.memo + useCallback / useMemo work together
  • Lift state down: keep state close to where it’s used
  • Lazy-load heavy routes and modals