DEV Community

Cover image for React Performance Optimisation: 10 Patterns Senior Devs Use
Waqar Habib
Waqar Habib Subscriber

Posted on

React Performance Optimisation: 10 Patterns Senior Devs Use

React performance problems have a signature. The app feels fine in development, snappy on your MacBook Pro, then gets to a real user on a mid-range Windows laptop or an Android phone in Texas, and suddenly it's sluggish.

The fixes aren't magic. They're patterns. Specific, repeatable, applicable to most React codebases. Here are the ten I reach for most often when profiling real production applications for US clients.


1. Profile First, Optimise Second

This sounds obvious until you see how many teams optimise by intuition rather than data. React DevTools Profiler is free and ships with the browser extension. Use it before touching any code.

Open the Profiler tab, hit Record, reproduce the slowness, stop recording. You'll see a flame graph of every component render with its duration. The wide, tall bars are your targets.

Only optimise what shows up here. Premature optimisation is real and it makes codebases harder to maintain for no benefit.


2. useMemo for Expensive Calculations

// Problem: recalculates on every render, even when input hasn't changed
function ProductList({ products, filters }: Props) {
  const filtered = products
    .filter(p => matchesFilters(p, filters))    // potentially O(n)
    .sort((a, b) => a.price - b.price);         // O(n log n)

  return filtered.map(p => <ProductCard key={p.id} product={p} />);
}

// Solution: only recalculate when products or filters change
function ProductList({ products, filters }: Props) {
  const filtered = useMemo(
    () => products
      .filter(p => matchesFilters(p, filters))
      .sort((a, b) => a.price - b.price),
    [products, filters] // dependency array
  );

  return filtered.map(p => <ProductCard key={p.id} product={p} />);
}
Enter fullscreen mode Exit fullscreen mode

The rule of thumb: useMemo is worth it when the calculation is expensive (>1ms) and the component re-renders frequently with the same inputs.


3. useCallback for Stable Function References

Functions created inside a component are re-created on every render. When you pass them as props, child components see a "new" function each time even if the logic is identical and re-render unnecessarily.

// Problem: new function reference every render → child always re-renders
function SearchPage() {
  const [query, setQuery] = useState('');

  const handleSearch = (term: string) => {
    analytics.track('search', { term });
    setQuery(term);
  };

  return <SearchInput onSearch={handleSearch} />;
}

// Solution: stable reference unless dependencies change
function SearchPage() {
  const [query, setQuery] = useState('');

  const handleSearch = useCallback((term: string) => {
    analytics.track('search', { term });
    setQuery(term);
  }, []); // empty array: created once, never changes

  return <SearchInput onSearch={handleSearch} />;
}
Enter fullscreen mode Exit fullscreen mode

Pair useCallback with React.memo on the child component to make it effective.


4. React.memo to Skip Child Renders

By default, when a parent re-renders, all its children re-render even if their props haven't changed. React.memo adds a shallow props comparison:

// Without memo: re-renders whenever parent re-renders
function UserCard({ user }: { user: User }) {
  return <div>{user.name}  {user.email}</div>;
}

// With memo: skips re-render if user prop is the same reference
const UserCard = React.memo(function UserCard({ user }: { user: User }) {
  return <div>{user.name}  {user.email}</div>;
});

// With custom comparison: deeper control
const UserCard = React.memo(
  function UserCard({ user }: { user: User }) {
    return <div>{user.name}  {user.email}</div>;
  },
  (prev, next) => prev.user.id === next.user.id && prev.user.updatedAt === next.user.updatedAt
);
Enter fullscreen mode Exit fullscreen mode

React.memo is most valuable on components that render frequently, receive the same props often, and have a non-trivial render function.


5. Code Splitting with React.lazy

Loading your entire application bundle upfront means users wait for code they may never use. Split by route at minimum:

import React, { Suspense, lazy } from 'react';
import { Routes, Route } from 'react-router-dom';

// These load on-demand, not upfront
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Settings = lazy(() => import('./pages/Settings'));
const BillingPage = lazy(() => import('./pages/BillingPage'));

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/analytics" element={<Analytics />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/billing" element={<BillingPage />} />
      </Routes>
    </Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode

The Analytics and BillingPage bundles don't load until the user navigates there. Initial load time drops proportionally to how many routes you split.


6. Virtualisation for Long Lists

Rendering 1,000+ items into the DOM is slow regardless of how optimised each item is. The DOM itself is the bottleneck. Virtualisation renders only what's visible in the viewport:

import { FixedSizeList as List } from 'react-window';

function TransactionList({ transactions }: { transactions: Transaction[] }) {
  const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
    <div style={style}> {/* style from react-window includes position */}
      <TransactionRow transaction={transactions[index]} />
    </div>
  );

  return (
    <List
      height={600}       // visible viewport height
      itemCount={transactions.length}
      itemSize={72}      // row height in px
      width="100%"
    >
      {Row}
    </List>
  );
}
Enter fullscreen mode Exit fullscreen mode

With react-window, 10,000 items renders the same as 20 because only ~10 are in the DOM at any time.


7. Debounce User Inputs That Trigger Expensive Operations

Every keystroke in a search box triggering a network request or expensive filter operation is unnecessary:

import { useDeferredValue, useState } from 'react';

function SearchPage() {
  const [inputValue, setInputValue] = useState('');
  const deferredValue = useDeferredValue(inputValue); // built-in React 18 debounce

  return (
    <>
      <input
        value={inputValue}
        onChange={e => setInputValue(e.target.value)}
        placeholder="Search..."
      />
      {/* Results use deferredValue — renders don't block input updates */}
      <SearchResults query={deferredValue} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Or with a traditional debounce hook for API calls:

function useDebounce<T>(value: T, delayMs: number): T {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(timer);
  }, [value, delayMs]);
  return debounced;
}

const debouncedQuery = useDebounce(searchInput, 300);
// Only fires an API request 300ms after the user stops typing
Enter fullscreen mode Exit fullscreen mode

8. Context Splitting to Prevent Global Re-renders

React context re-renders every consumer when any value in the context changes. A single large context object means a settings change can re-render your entire navigation, sidebar, and dashboard simultaneously.

Split contexts by update frequency:

// Bad: one context re-renders everything
const AppContext = createContext({ user, theme, notifications, permissions, featureFlags });

// Good: split by how often each piece changes
const UserContext = createContext(user);           // changes on login/logout
const ThemeContext = createContext(theme);          // changes on theme toggle
const NotificationContext = createContext(notifications); // changes frequently
const PermissionContext = createContext(permissions); // rarely changes
Enter fullscreen mode Exit fullscreen mode

Components only subscribe to the context they actually use, and only re-render when that specific context changes.


9. Avoid Object and Array Literals in JSX Props

This is a subtle one that causes unnecessary re-renders in deeply nested trees:

// Problem: new object reference on every render, even if values are identical
function Chart({ data }: Props) {
  return (
    <LineChart
      data={data}
      options={{ responsive: true, animation: false }} // new object every render
      style={{ width: '100%', height: 400 }}           // new object every render
    />
  );
}

// Solution: define outside the component, or memoize
const chartOptions = { responsive: true, animation: false };
const chartStyle = { width: '100%', height: 400 };

function Chart({ data }: Props) {
  return (
    <LineChart
      data={data}
      options={chartOptions} // stable reference
      style={chartStyle}     // stable reference
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

If the object depends on props, use useMemo. If it's static, define it outside the component.


10. useTransition for Non-Urgent State Updates

React 18's useTransition lets you mark some state updates as non-urgent, keeping the UI responsive while expensive renders happen in the background:

import { useState, useTransition } from 'react';

function FilterPanel({ items }: { items: Item[] }) {
  const [filter, setFilter] = useState('all');
  const [filteredItems, setFilteredItems] = useState(items);
  const [isPending, startTransition] = useTransition();

  function handleFilterChange(newFilter: string) {
    setFilter(newFilter); // urgent: update the UI immediately

    startTransition(() => {
      // non-urgent: can be interrupted if user changes filter again
      setFilteredItems(items.filter(item => matchesFilter(item, newFilter)));
    });
  }

  return (
    <>
      <FilterControls value={filter} onChange={handleFilterChange} />
      {isPending && <div className="loading-overlay" />}
      <ItemGrid items={filteredItems} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

The user sees the filter control update instantly. The expensive item grid re-render happens in the background and can be interrupted if the user clicks again.


The 20/80 Rule for React Performance

In practice, 80% of React performance wins come from three things: eliminating unnecessary re-renders with memo and useCallback, code splitting routes, and virtualising long lists. The other seven patterns handle the remaining 20% and specific edge cases.

Profile first. Fix what the data says is slow. The patterns above are the vocabulary. The profiler tells you which ones to apply and where.

Need a full-stack React application built to production performance standards for a US market? That's the work I do. See my approach at waqarhabib.com/services/full-stack-development.


Originally published at waqarhabib.com

Top comments (0)