A slow React app is almost always the result of avoidable re-renders, oversized bundles, or blocking the main thread with expensive computations. This guide walks through every major optimisation lever available in modern React (v18+), with concrete before-and-after code so you know exactly what to change and why.
1. Understand the Render Cycle First
Before optimising, always profile. React DevTools Profiler shows exactly which components re-render, how long they take, and why they rendered. Open DevTools → Profiler tab → record an interaction, then look for components with high self-time or unexpected render counts.
function ExpensiveComponent({ data }) {
console.count('ExpensiveComponent render');
return <div>{data.name}</div>;
}
// If you see this firing more than expected, you have a re-render problem.2. React.memo — Skip Re-renders for Pure Components
// BEFORE — re-renders every time the parent renders
function UserCard({ user }: { user: User }) {
return <div>{user.name} — {user.email}</div>;
}
// AFTER — only re-renders when `user` reference changes
const UserCard = React.memo(function UserCard({ user }: { user: User }) {
return <div>{user.name} — {user.email}</div>;
});
// Custom comparator for deep equality or subset checks
const UserCard = React.memo(UserCardBase, (prev, next) => {
return prev.user.id === next.user.id && prev.user.name === next.user.name;
});Do not wrap every component in React.memo. The shallow comparison itself has a cost. Only apply it when you have profiler evidence that the component re-renders unnecessarily and the render is measurably expensive.
3. useMemo and useCallback — Stable References
function Dashboard({ userId }: { userId: string }) {
const [filter, setFilter] = useState('active');
// BAD: new array reference on every render
const filtered = users.filter(u => u.status === filter);
// GOOD: only recomputes when users or filter changes
const filtered = useMemo(() => users.filter(u => u.status === filter), [users, filter]);
// BAD: new function reference on every render
const handleDelete = (id: string) => deleteUser(id);
// GOOD: stable reference
const handleDelete = useCallback((id: string) => deleteUser(id), [deleteUser]);
return <UserList users={filtered} onDelete={handleDelete} />;
}4. Code Splitting with React.lazy and Suspense
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Settings = lazy(() => import('./pages/Settings'));
export default function App() {
return (
<BrowserRouter>
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}5. List Virtualisation — Rendering Only Visible Items
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: () => 56,
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((vItem) => (
<div key={vItem.key} style={{ transform: `translateY(${vItem.start}px)`, position: 'absolute', width: '100%' }}>
<Row item={items[vItem.index]} />
</div>
))}
</div>
</div>
);
}6. Avoid Context Re-render Storms
// BAD: one big context — a theme change re-renders auth consumers
const AppContext = createContext({ user, theme, settings });
// GOOD: split contexts by update frequency
const UserContext = createContext<User | null>(null);
const ThemeContext = createContext<Theme>('dark');
// Even better: Zustand/Jotai selectors
const useUser = () => useStore((s) => s.user);
const useTheme = () => useStore((s) => s.theme);7. useDeferredValue and useTransition (React 18)
function SearchPage() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query); // stale value while typing
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<Results query={deferredQuery} /> {/* never blocks input */}
</>
);
}8. Bundle Size Analysis
npx vite-bundle-visualizer
# or for webpack
npx webpack-bundle-analyzer build/static/js/*.js
# Check package size before installing
npx bundlephobia lodash # 71 kB — use lodash-es or import by function
npx bundlephobia dayjs # 6.5 kB — good alternative to moment.js9. Web Vitals: What to Measure
- LCP (Largest Contentful Paint) < 2.5 s — Preload hero images, use next/image, serve WebP/AVIF.
- INP (Interaction to Next Paint) < 200 ms — Keep event handlers fast; use startTransition for non-urgent updates.
- CLS (Cumulative Layout Shift) < 0.1 — Always set explicit width/height on images and iframes.
- TTFB (Time to First Byte) < 800 ms — Use SSR or SSG; edge caching cuts TTFB dramatically.
10. Quick Wins Checklist
- Run React in production mode — development mode can be 2–8× slower.
- Set key props correctly in lists — never use array index as key for reorderable or dynamic lists.
- Move state as close to where it is used as possible — lifting state too high causes unnecessary re-renders.
- Avoid inline object/array literals in JSX props — they create new references on every render.
- Use the React Compiler (React 19+) — it auto-memoises components without manual useMemo/useCallback.
Always measure before and after each optimisation with Lighthouse or the React Profiler. Premature optimisation wastes time and adds complexity. Target real bottlenecks, not imagined ones.
Struggling with a slow React application? BitPixel Coders audits and optimises React, Next.js, and React Native codebases — get in touch for a free performance review.
Get a Free Consultation