Performance Optimization in React

1. 📖 Introduction

React is fast by default, but as applications grow — more components, deeper trees, larger lists — performance problems inevitably creep in. This tutorial covers the full toolkit for diagnosing and fixing them: rendering optimization, memoization, code splitting, list virtualization, and profiling, along with common pitfalls to avoid.

Information

Performance work should always be guided by measurement, not guesswork. Section 27 covers the React Profiler, which should be your first stop before optimizing anything.

2. 🔍 Understanding React Performance

React's performance model centers on the render phase (computing what the UI should look like) and the commit phase (applying those changes to the real DOM). Most performance problems come from rendering too often or rendering too much at once.

State/Props Change
Render Phase (compute virtual DOM)
Diffing (compare with previous tree)
Commit Phase (update real DOM)

3. 🚧 Performance Bottlenecks

Common sources of slowness in React apps include:

  • Unnecessary re-renders cascading through a large component tree
  • Expensive calculations repeated on every render
  • Large lists rendering thousands of DOM nodes at once
  • Oversized JS bundles that slow initial page load
  • Unoptimized images and heavy assets

4. đŸŽ¯ Rendering Optimization

The single biggest lever for React performance is reducing how often, and how much, gets re-rendered. This involves both preventing unnecessary re-renders and making necessary ones cheaper.

5. 🔄 Re-rendering

A component re-renders whenever its state changes, its props change, or its parent re-renders — even if the new props are identical in value. Understanding this cascade is the foundation of all optimization work.

Note

By default, when a parent component re-renders, all of its children re-render too, regardless of whether their own props actually changed.

6. đŸ›Ąī¸ Preventing Unnecessary Re-renders

React provides three core tools to prevent wasted re-renders: React.memo for components, useMemo for computed values, and useCallback for function references. Each is covered in detail below.

7. 🧠 React.memo

React.memo wraps a component so it only re-renders when its props actually change (via a shallow comparison), skipping re-renders triggered purely by a parent update.

MemoExample.jsx

const ProductCard = React.memo(function ProductCard({ product }) {
  console.log("Rendering", product.name);
  return <div>{product.name} - {product.price{"}"}</div>;
});

Caution

React.memo only helps if the props are referentially stable — passing a new object or function literal on every parent render defeats it entirely.

8. 🧮 useMemo

useMemo caches the result of an expensive calculation, recomputing it only when its dependencies change.

UseMemoExample.jsx

function ProductList({ products, filterText }) {
  const filtered = useMemo(
    () => products.filter((p) => p.name.includes(filterText)),
    [products, filterText]
  );

  return <ul>{filtered.map((p) => <li key={p.id}>{p.name}</li>)}</ul>;
}

Warning

Reach for useMemo when a calculation is genuinely expensive (large arrays, complex math) — wrapping trivial computations adds overhead without meaningful benefit.

9. 🔗 useCallback

useCallback caches a function reference across renders, which matters most when that function is passed as a prop to a React.memo-wrapped child.

UseCallbackExample.jsx

function ParentComponent() {
  const [count, setCount] = useState(0);

  const handleClick = useCallback(() => {
    console.log("Clicked!");
  }, []);

  return <MemoizedButton onClick={handleClick} />;
}

Best Practice

useCallback is only useful when paired with React.memo on the receiving component — otherwise, the child re-renders anyway and the cached reference provides no benefit.

10. 💤 Lazy Loading

Lazy loading defers downloading and executing a component's code until it's actually needed — for example, a modal or settings panel the user might never open.

11. đŸĻĨ React.lazy

React.lazy wraps a dynamic import() call, turning a component into one that loads its code on demand.

ReactLazyExample.jsx

const SettingsPanel = React.lazy(() => import("./SettingsPanel"));

12. â¸ī¸ Suspense

<Suspense> displays a fallback UI while a lazy-loaded component's code is still downloading.

SuspenseExample.jsx

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <SettingsPanel />
    </Suspense>
  );
}

13. âœ‚ī¸ Code Splitting

Code splitting breaks a single large JS bundle into smaller chunks, loaded only when needed — most commonly split by route or by heavy, rarely-used features.

RouteSplitting.jsx

const Dashboard = React.lazy(() => import("./pages/Dashboard"));
const Settings = React.lazy(() => import("./pages/Settings"));

<Routes>
  <Route path="/dashboard" element={<Suspense fallback={<Spinner />}><Dashboard /></Suspense>} />
  <Route path="/settings" element={<Suspense fallback={<Spinner />}><Settings /></Suspense>} />
</Routes>

14. đŸ“Ļ Dynamic Imports

Beyond React.lazy, plain dynamic import() can defer loading non-component code too — like a heavy charting library only needed after a button click.

DynamicImport.jsx

async function exportToPdf(data) {
  const { generatePdf } = await import("./pdfGenerator");
  return generatePdf(data);
}

15. 🧩 Component Splitting

Breaking a large component into smaller ones isn't just for readability — it also isolates re-renders. A state update in a small subcomponent no longer forces the entire parent tree to re-render.

ComponentSplitting.jsx

// Before: one state change re-renders the whole page
function Page() {
  const [query, setQuery] = useState("");
  return (
    <div>
      <SearchInput query={query} onChange={setQuery} />
      <ExpensiveChart /> {/* re-renders on every keystroke, unnecessarily */}
    </div>
  );
}

// After: isolate the frequently-changing piece
function Page() {
  return (
    <div>
      <SearchBox />
      <ExpensiveChart />
    </div>
  );
}

16. đŸ–ŧī¸ Image Optimization

Images are often the heaviest assets on a page. Use appropriately sized images, modern formats (WebP, AVIF), and the native loading="lazy" attribute to defer offscreen images.

LazyImage.jsx

<img src="/hero.webp" alt="Product hero" loading="lazy" width={800} height={400} />

Tip

Always set explicit width and height on images to prevent layout shift while they load.

17. 📜 List Virtualization

Virtualization renders only the visible rows of a long list, plus a small buffer — instead of mounting thousands of DOM nodes at once.

VirtualizedList.jsx

import { FixedSizeList } from "react-window";

function ProductList({ products }) {
  const Row = ({ index, style }) => (
    <div style={style}>{products[index].name}</div>
  );

  return (
    <FixedSizeList height={600} itemCount={products.length} itemSize={50} width="100%">
      {Row}
    </FixedSizeList>
  );
}

18. đŸĒŸ Windowing

"Windowing" is another name for the same technique as virtualization — only a small window of rows exists in the DOM at any time, with the rest tracked purely in memory. Libraries like react-window and react-virtualized implement this pattern.

19. âąī¸ Debouncing

Debouncing delays running a function until the user has stopped triggering it for a set period — ideal for search-as-you-type inputs that shouldn't fire a request on every keystroke.

Debounce.jsx

function useDebouncedValue(value, delay) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timeout = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timeout);
  }, [value, delay]);

  return debounced;
}

20. đŸšĻ Throttling

Throttling limits a function to running at most once per interval, regardless of how many times it's triggered — well-suited to high-frequency events like scroll or resize.

Throttle.jsx

function throttle(fn, limit) {
  let waiting = false;
  return (...args) => {
    if (!waiting) {
      fn(...args);
      waiting = true;
      setTimeout(() => (waiting = false), limit);
    }
  };
}

Reference

Debouncing waits for a pause before firing; throttling fires at a steady, capped rate regardless of pauses.

21. đŸ—ƒī¸ Memoization Strategies

Choosing what to memoize is as important as knowing how. A good rule of thumb: memoize expensive computations and stable references passed to memo-wrapped children — not everything, everywhere.

ToolMemoizesUse when
React.memoA component's render outputComponent re-renders often with unchanged props
useMemoA computed valueCalculation is expensive (sorting, filtering large data)
useCallbackA function referenceFunction is passed to a memo-wrapped child

22. đŸ—‚ī¸ State Optimization

Keeping state as local as possible — close to where it's used — limits how much of the tree re-renders when it changes. Lifting state up unnecessarily is a common source of avoidable re-renders.

LocalState.jsx

// Better: dropdown's open/closed state stays local
function Dropdown() {
  const [isOpen, setIsOpen] = useState(false);
  // only this small component re-renders on toggle
}

23. 🌐 Context Optimization

Every component consuming a Context re-renders whenever any part of that context's value changes — even if the consumer only cares about one field. Split large contexts into smaller, focused ones to limit this blast radius.

SplitContext.jsx

// Instead of one large AppContext with { user, theme, cart }...
const UserContext = createContext(null);
const ThemeContext = createContext(null);
const CartContext = createContext(null);

// Components only re-render when the specific context they use changes

24. đŸ“Ļ Bundle Size Optimization

A smaller JS bundle means faster downloads and faster parsing, directly improving initial load time. Key techniques: code splitting (Section 13), tree shaking (Section 25), and auditing dependencies for lighter alternatives.

25. đŸŒŗ Tree Shaking

Tree shaking is a build-tool optimization that removes unused exports from the final bundle. It works best with ESM import/export syntax, since bundlers can statically analyze what's actually used.

TreeShakingExample.jsx

// Good: only imports what's used, tree-shakeable
import { debounce } from "lodash-es";

// Avoid: imports the entire library
import _ from "lodash";

26. đŸ—ī¸ Production Builds

Always measure performance using a production build, never the development server — React's development mode includes extra warnings and checks that significantly slow down rendering.

terminal

npm run build
npm run preview

Important

Performance measurements taken in development mode are not representative of what real users will experience — always profile against a production build.

27. 📊 React Profiler

The React DevTools Profiler records how long each component takes to render and why it re-rendered, making it the primary tool for diagnosing real bottlenecks rather than guessing.

28. đŸ”Ŧ Profiling Components

React also exposes a <Profiler> component for programmatic measurement, useful for automated performance regression tracking.

ProfilerComponent.jsx

import { Profiler } from "react";

function onRender(id, phase, actualDuration) {
  console.log(`${id} (${phase}) took ${actualDuration}ms`);
}

<Profiler id="ProductList" onRender={onRender}>
  <ProductList products={products} />
</Profiler>

29. 📈 Performance Monitoring

Beyond local profiling, production monitoring tools (like RUM services or Web Vitals reporting) track real-world metrics — Largest Contentful Paint, Interaction to Next Paint, and similar — across your actual user base.

30. 🧠 Memory Optimization

Beyond render speed, React apps can suffer from memory growth over time — often from leaked subscriptions, timers, or event listeners that never get cleaned up.

31. 🚰 Avoiding Memory Leaks

Always clean up subscriptions, timers, and listeners in a useEffect cleanup function, or they'll keep running — and holding references — long after a component unmounts.

CleanupEffect.jsx

useEffect(() => {
  const handleResize = () => console.log(window.innerWidth);
  window.addEventListener("resize", handleResize);

  return () => window.removeEventListener("resize", handleResize);
}, []);

32. đŸ› ī¸ Optimizing Effects

Keep useEffect dependency arrays accurate and minimal. Effects that run more often than necessary — or that omit needed dependencies — are a frequent, subtle source of wasted work and bugs.

Caution

Silencing the exhaustive-deps lint rule to "fix" an effect running too often usually just hides a stale-closure bug rather than solving the real problem.

33. đŸ–ąī¸ Optimizing Event Handlers

For frequent events like onScroll or onMouseMove, combine throttling/debouncing (Sections 19–20) with useCallback to avoid both excessive handler invocations and unnecessary child re-renders.

34. 📋 Optimizing Lists

Beyond virtualization, always give list items a stable, unique key — ideally an ID, never an array index for lists that reorder — so React can correctly match and reuse existing DOM nodes.

StableKeys.jsx

{products.map((product) => (
  <ProductCard key={product.id} product={product} />
))}

35. 📝 Optimizing Forms

Large, highly controlled forms can re-render the entire form on every keystroke. Splitting fields into isolated subcomponents, or using an uncontrolled approach via a library like React Hook Form, keeps typing responsive even in big forms.

36. đŸšĢ Performance Anti-Patterns

  • Creating new object or array literals inline as props (style={{...}}) on every render
  • Defining components inside another component's body, causing them to remount on every render
  • Wrapping every single component in React.memo "just in case," adding overhead without benefit
  • Storing large, frequently-changing values in a single top-level context

AntiPatternExample.jsx

// Bad: InnerComponent is redefined and remounted every render
function Parent() {
  function InnerComponent() { return <div>Hi</div>; }
  return <InnerComponent />;
}

// Good: define components outside the render function
function InnerComponent() { return <div>Hi</div>; }
function Parent() {
  return <InnerComponent />;
}

37. 🔷 TypeScript Considerations

TypeScript itself doesn't affect runtime performance, but strict typing on memoized values and callback signatures helps catch a common bug: accidentally passing a differently-shaped prop that defeats React.memo's comparison.

TypedMemo.tsx

interface ProductCardProps {
  product: { id: string; name: string; price: number };
}

const ProductCard = React.memo(function ProductCard({ product }: ProductCardProps) {
  return <div>{product.name}</div>;
});

38. 🏆 Best Practices

  1. Always measure first with the Profiler before optimizing anything
  2. Keep state as local as possible to limit re-render scope
  3. Virtualize any list rendering more than a few hundred rows
  4. Code-split by route and by rarely-used, heavy features
  5. Use React.memo, useMemo, and useCallback deliberately — not by default everywhere

39. âš ī¸ Common Mistakes

  • Optimizing based on intuition rather than actual profiler data
  • Adding useMemo/useCallback to trivial values, adding overhead with no real gain
  • Forgetting that React.memo does nothing if new object/function props are created every render
  • Rendering huge lists without virtualization
  • Not cleaning up effects, leading to slow memory growth over a long session

Danger

Wrapping a component in React.memo while still passing it a freshly-created object or arrow function as a prop provides zero benefit — the shallow comparison will always see a "new" prop and re-render anyway.

40. đŸ’Ŧ Frequently Asked Questions

Should I wrap every component in React.memo?

No — React.memo adds a comparison cost on every render. Reserve it for components that render often with genuinely unchanged props, especially ones with expensive render logic.

How do I know if my app actually has a performance problem?

Use the React DevTools Profiler on a production build to look for components with unexpectedly long or frequent render times before assuming optimization is needed at all.

Is virtualization always necessary for long lists?

Not always — lists of a few dozen items are usually fine unmodified. Virtualization becomes valuable once lists reach hundreds or thousands of rows.

41. 📌 Summary

>>The fastest code is the code that never runs — the best optimization is often simply doing less work.