React
January 15, 2024
11 min read
6 views

Optimizing React Applications: Performance Best Practices

A practical, code-first guide to React performance — memoisation, code splitting, list virtualisation, Context pitfalls, bundle analysis, Web Vitals profiling, and Suspense — with before/after examples for every technique.

React Performance OptimizationReact.memouseMemo useCallbackCode Splitting ReactReact VirtualizationWeb Vitals ReactReact Bundle SizeReact Suspense LazyReact ProfilerContext Performance
Optimizing React Applications: Performance Best Practices

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.

javascript
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

typescript
// 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

typescript
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

typescript
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

typescript
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

typescript
// 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)

typescript
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

bash
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.js

9. 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