Articles › React

React Performance Optimization: Re-renders, Memoization, Code Splitting and Virtualization

Make React apps fast: understand re-renders, measure with the Profiler, use memo, useMemo and useCallback wisely, split code, virtualize long lists, defer work and optimize images.

Most React apps are fast by default. When they're not, the cause is usually one of a few patterns: components re-rendering far more than needed, expensive work on every render, too much JavaScript on first load, or thousands of DOM nodes. This guide shows how to find the real bottleneck and apply the right fix — without sprinkling useMemo everywhere.

Measure first

Optimizing blind wastes time and makes code harder to read. Use:

  • React DevTools Profiler — records renders, shows which components rendered, why, and how long they took. Enable "Highlight updates when components render" to see re-renders live.
  • Chrome Performance panel — long tasks, layout thrashing, scripting time.
  • Lighthouse / Core Web Vitals — real-world loading and interaction metrics (LCP, INP, CLS).

Fix what the measurements point to.

Why components re-render

A component re-renders when:

  1. Its state changes,
  2. Its parent re-renders (by default, children re-render too),
  3. A context it consumes changes.

Re-rendering isn't automatically bad — React only touches the DOM where output actually changed. It becomes a problem when a re-render is expensive or happens constantly (typing, scrolling, animations).

1. Keep state close to where it's used

The cheapest optimization is structural. State high in the tree re-renders everything below it:

// ❌ Typing in the search box re-renders the whole page
function Page() {
  const [query, setQuery] = useState("");
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <HeavyChart />
      <ArticleGrid />
    </>
  );
}

// ✅ Move the state down into its own component
function SearchBox() {
  const [query, setQuery] = useState("");
  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

Another trick: pass expensive subtrees as children — they're created by the parent and don't re-render when the wrapper's state changes:

function ScrollTracker({ children }) {
  const [y, setY] = useState(0);
  // ... updates y on scroll
  return <div data-y={y}>{children}</div>;   // children don't re-render on scroll
}

2. React.memo for pure components

React.memo skips re-rendering a component when its props are shallowly equal:

const ArticleCard = memo(function ArticleCard({ article, onOpen }) {
  return (
    <article onClick={() => onOpen(article.slug)}>
      <h3>{article.title}</h3>
    </article>
  );
});

It only helps if props are stable. A new object or function on every parent render defeats it — that's where useMemo and useCallback come in.

3. useMemo and useCallback — with purpose

function ArticleList({ articles, tag, onOpen }) {
  const visible = useMemo(
    () => articles.filter((a) => !tag || a.tag === tag).sort(byDateDesc),
    [articles, tag]
  );

  const handleOpen = useCallback((slug) => onOpen(slug), [onOpen]);

  return visible.map((a) => <ArticleCard key={a.id} article={a} onOpen={handleOpen} />);
}

Use them when:

  • a calculation is genuinely expensive (measurable), or
  • a value/function is passed to a memoized child or used as an effect dependency.

Don't use them for cheap calculations "just in case" — memoization has its own cost and adds noise. The React Compiler can now insert most of this memoization automatically; if your project uses it, write plain code and let the compiler optimize.

4. Stable keys in lists

// ❌ index keys break state and cause extra work when items are inserted or reordered
items.map((item, i) => <Row key={i} item={item} />)

// ✅ stable unique ids
items.map((item) => <Row key={item.id} item={item} />)

5. Split contexts and avoid giant providers

When a context value changes, every consumer re-renders. Split contexts by update frequency (ThemeContext vs CartContext), memoize provider values, or use a store with selectors (Zustand, Redux) for frequently changing state.

6. Code splitting: ship less JavaScript

Users shouldn't download the admin panel to read an article:

const AdminPanel = lazy(() => import("./admin/AdminPanel"));
const ArticlePage = lazy(() => import("./pages/ArticlePage"));

<Suspense fallback={<Spinner />}>
  <Routes>
    <Route path="/admin/*" element={<AdminPanel />} />
    <Route path="/articles/:slug" element={<ArticlePage />} />
  </Routes>
</Suspense>

Also:

  • Import only what you use (import { debounce } from "lodash-es", not the whole library).
  • Analyze bundles with rollup-plugin-visualizer or vite-bundle-visualizer.
  • Lazy-load heavy libraries (charts, editors, syntax highlighters) inside the components that need them.

On this site, moving Markdown parsing and syntax highlighting into the lazily loaded article page cut the main bundle by roughly 40%.

7. Virtualize long lists

Rendering 5,000 rows creates 5,000 DOM nodes. Virtualization renders only the visible ones:

import { useVirtualizer } from "@tanstack/react-virtual";

function LogList({ rows }) {
  const parentRef = useRef(null);
  const virtualizer = useVirtualizer({
    count: rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 48,
  });

  return (
    <div ref={parentRef} style={{ height: 600, overflow: "auto" }}>
      <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
        {virtualizer.getVirtualItems().map((v) => (
          <div key={v.key} style={{ position: "absolute", top: 0, transform: `translateY(${v.start}px)`, height: v.size, width: "100%" }}>
            {rows[v.index].message}
          </div>
        ))}
      </div>
    </div>
  );
}

For moderately long lists, pagination or "load more" is often simpler and better UX.

8. Keep interactions responsive with transitions

const [query, setQuery] = useState("");
const [isPending, startTransition] = useTransition();
const [results, setResults] = useState(allArticles);

function handleChange(e) {
  setQuery(e.target.value);                  // urgent: update the input
  startTransition(() => {
    setResults(filterArticles(allArticles, e.target.value)); // non-urgent
  });
}

useTransition (or useDeferredValue) lets React keep typing smooth while a heavy list re-renders in the background — directly improving the INP metric.

9. Debounce and throttle expensive events

Search-as-you-type, resize and scroll handlers can fire dozens of times per second. Debounce network requests; throttle scroll handlers or use IntersectionObserver instead of scroll events for visibility detection.

10. Images and assets

Often the biggest win isn't React at all:

  • Serve modern formats (WebP/AVIF) at the right size; use srcset.
  • Add loading="lazy" to below-the-fold images and explicit width/height to prevent layout shift.
  • Preload the hero image (fetchpriority="high").
  • Self-host and subset fonts; use font-display: swap.

11. Animate the cheap properties

Animate transform and opacity — they're handled by the compositor. Animating width, top or box-shadow triggers layout or paint on every frame. Libraries like GSAP make this easy, and prefers-reduced-motion should disable non-essential motion.

Checklist

Symptom Likely fix
Typing feels laggy move state down, useTransition, debounce
Whole page re-renders on small changes split components/contexts, memo
Slow first load code splitting, smaller bundles, image optimization
Huge list scrolls poorly virtualization or pagination
Janky animations animate transform/opacity, avoid layout thrashing

Summary

Measure before optimizing. Then fix structure first — keep state local, split contexts, pass children — and reach for memo, useMemo and useCallback only where profiling shows real cost. Ship less JavaScript with code splitting, virtualize big lists, keep interactions responsive with transitions, and don't forget images, fonts and animation properties. Fast React apps are mostly the result of good architecture, not micro-optimizations.

FAQ

Should I wrap every component in React.memo?

No. It adds comparison overhead and complexity. Use it for components that re-render often with the same props and are expensive to render.

Does StrictMode slow down my app?

Only in development, where it intentionally double-invokes some functions. Production builds aren't affected.

Is React slower than other frameworks?

For typical apps the difference is rarely noticeable. Architecture, bundle size and asset optimization matter much more than the framework choice.