Published at

React Performance Optimization

Table of Contents

React is fast by default — but as your application grows, performance bottlenecks will eventually appear. Deeply nested component trees, expensive computations, large data sets, and frequent state updates all conspire to slow things down. The good news is that React’s modern tooling gives you a powerful arsenal to address these issues.

But React performance optimization is not about adding useMemo, useCallback, and React.memo everywhere. In many applications, React is already fast enough, and premature optimization can make code harder to read without meaningfully improving the user experience. A better approach is to understand how React renders, measure where the app is slow, and then apply targeted optimizations.

This guide walks through practical performance techniques for intermediate developers: rendering behavior, state placement, memoization, list rendering, code splitting, concurrent features, and newer tools like React Compiler.

Start by Measuring, Not Guessing

The first rule of React performance optimization is simple: profile before changing code.

A slow React app can have many causes — expensive rendering, unnecessary re-renders, large JavaScript bundles, slow network requests, large lists, or heavy third-party packages. If you guess, you may optimize the wrong thing.

React provides the <Profiler> component for measuring rendering performance programmatically. Wrap any part of your component tree and receive timing information whenever that subtree commits an update:

import { Profiler } from 'react';

function onRender(id, phase, actualDuration, baseDuration) {
  console.log({ id, phase, actualDuration, baseDuration });
}

export default function App() {
  return (
    <Profiler id="Dashboard" onRender={onRender}>
      <Dashboard />
    </Profiler>
  );
}

actualDuration tells you how long the current render took. baseDuration estimates the cost of rendering the whole subtree without memoization. Comparing the two can help you understand whether memoization is meaningfully reducing render work.

For interactive debugging, React DevTools includes a Profiler tab that lets you record a session and visualize which components rendered and why.

When measuring, avoid relying on development mode. React’s development mode includes extra checks and warnings, and Strict Mode intentionally double-invokes render functions — both of which can distort timing results. For more realistic numbers, use a production build or a production profiling build.

For production-level insight, add Web Vitals monitoring. The three metrics that matter most for React apps are:

  • Interaction to Next Paint (INP) — how responsive your app feels during user interaction
  • Largest Contentful Paint (LCP) — how fast the main content loads
  • Cumulative Layout Shift (CLS) — visual stability
import { onINP, onLCP, onCLS } from 'web-vitals';

onINP(console.log);
onLCP(console.log);
onCLS(console.log);

Only after identifying the real bottleneck should you reach for the techniques below.

Understand What Re-rendering Actually Means

A common misunderstanding is that every React re-render is automatically bad. It is not.

A render means React calls your component function again to calculate what the UI should look like. It does not necessarily mean the browser DOM is updated — React compares the result and commits only the necessary changes.

However, rendering becomes expensive when a component tree is large, when render logic performs heavy calculations, or when re-renders cascade through many children. When a component’s state changes, React re-renders that component and its children unless something prevents it. That may be fine if those children are cheap. It becomes a problem when they are not.

The goal is not to eliminate every render. The goal is to prevent expensive, unnecessary work that users can actually feel.

Keep State as Local as Possible

One of the most effective React performance techniques is not a hook. It is better state placement.

If a piece of state only affects one small part of the UI, keep it close to that part. Do not lift it into a large parent component unless multiple children genuinely need it.

Consider this common pattern:

function Dashboard() {
  const [search, setSearch] = useState('');

  return (
    <>
      <SearchBox value={search} onChange={setSearch} />
      <AnalyticsPanel />
      <BillingPanel />
      <ActivityFeed search={search} />
    </>
  );
}

Every keystroke re-renders Dashboard, which in turn re-renders AnalyticsPanel and BillingPanel — even though neither of them cares about the search value. Moving the state down fixes this:

function Dashboard() {
  return (
    <>
      <ActivitySearch />
      <AnalyticsPanel />
      <BillingPanel />
    </>
  );
}

function ActivitySearch() {
  const [search, setSearch] = useState('');
  return (
    <>
      <SearchBox value={search} onChange={setSearch} />
      <ActivityFeed search={search} />
    </>
  );
}

Now keystrokes only re-render ActivitySearch and its children. No memoization needed.

The same principle applies to context. Context is useful for app-wide state like theme, auth, or locale. But if a context value changes frequently, every component that reads it may re-render. For high-frequency state, prefer local state, split contexts by concern, or use a dedicated state library with selectors like Zustand or Jotai.

Use React.memo for Expensive Children

React.memo lets you tell React to skip re-rendering a component when its props are unchanged. The React docs describe it as a performance optimization, not a guarantee — React may still re-render a memoized component, but usually it will skip re-rendering when props have not changed.

import { memo } from 'react';

const ReviewCard = memo(function ReviewCard({ review }) {
  return (
    <article>
      <h3>{review.author}</h3>
      <p>{review.body}</p>
    </article>
  );
});

This is useful when a component is expensive to render, often receives the same props, and its parent re-renders for unrelated reasons. It is less useful when the component is cheap, when props change on every render, or when the component reads a frequently-changing context.

A common mistake is wrapping a component in memo but passing unstable props:

// ❌ New object and function reference on every render — defeats memo
<ReviewCard
  review={review}
  style={{ marginBottom: 16 }}
  onSelect={() => selectReview(review.id)}
/>

Since memo compares props by reference using Object.is, these new values cause ReviewCard to re-render anyway. Better options: move stable objects outside the component, pass only the props that are needed, or use useMemo and useCallback to stabilize references — but only when the component is genuinely expensive and the props are genuinely stable most of the time.

Use useMemo for Expensive Calculations

useMemo caches the result of a calculation between renders. It is useful when a calculation is genuinely expensive and its dependencies do not change often.

import { useMemo } from 'react';

function ProductTable({ products, query }) {
  const visibleProducts = useMemo(() => {
    return products
      .filter((p) => p.name.toLowerCase().includes(query.toLowerCase()))
      .toSorted((a, b) => a.name.localeCompare(b.name));
  }, [products, query]);

  return <Table rows={visibleProducts} />;
}

Note the use of toSorted() rather than sort(). Array’s sort() mutates the original array in place — a silent bug when products is a prop, since React expects props to be treated as read-only. toSorted() returns a new array and leaves the original untouched. For older environments, spread first: [...products].sort(...).

useMemo will not make the first render faster — it only helps React reuse the previous result on subsequent renders when dependencies are unchanged. And it is not free: React has to store the cached value and compare dependencies on every render.

Do not reach for it by default:

// ❌ Unnecessary — string concatenation is not expensive
const fullName = useMemo(
  () => `${firstName} ${lastName}`,
  [firstName, lastName],
);

A useful test: would this calculation still matter if it ran 50 times in quick succession on a slower device? If yes, measure it. If it is genuinely expensive, memoize it.

Use useCallback for Stable Function Props

useCallback caches a function definition between renders. The reason to use it is not that creating functions is inherently slow — it usually is not. The problem arises when a function is passed to a memoized child component, because a new function reference on every render will break React.memo:

import { useCallback, memo } from 'react';

const ShippingForm = memo(function ShippingForm({ onSubmit }) {
  // expensive form
});

function ProductPage({ productId, referrer }) {
  const handleSubmit = useCallback(
    (orderDetails) => {
      post(`/product/${productId}/buy`, { referrer, orderDetails });
    },
    [productId, referrer],
  );

  return <ShippingForm onSubmit={handleSubmit} />;
}

Without useCallback, handleSubmit is a new function on every render. Even though ShippingForm is wrapped in memo, the changing function prop defeats the memoization. useCallback gives the function a stable reference that only changes when productId or referrer change.

Do not use useCallback everywhere — only when you need referential stability for a memoized child or a hook dependency.

Virtualize Long Lists

Rendering large lists is one of the most common React performance problems. If you render 5,000 rows, React must create and reconcile 5,000 elements, and the browser must manage the corresponding DOM nodes — regardless of how well the individual row components are optimized.

Virtualization (also called windowing) renders only the items currently visible in the viewport, plus a small buffer. react-window is a widely used option with a simple API:

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

function UsersList({ users }) {
  return (
    <List height={500} itemCount={users.length} itemSize={48} width="100%">
      {({ index, style }) => <div style={style}>{users[index].name}</div>}
    </List>
  );
}

TanStack Virtual is the more flexible modern alternative, supporting variable item sizes, horizontal lists, and grid layouts with a headless API. Either option can be more impactful than memoizing individual row components — if the DOM is too large, reducing the number of rendered nodes directly addresses the bottleneck.

One more thing: always use stable, unique key props on list items. Avoid array indices as keys when items can be reordered or removed — unstable keys cause React to unnecessarily unmount and remount components.

Split Large Bundles

Render performance is only part of the story. If users must download a large JavaScript bundle before the app becomes interactive, it will feel slow regardless of how fast the components render.

React’s lazy API loads a component only when it is rendered, and Suspense handles the loading state:

import { lazy, Suspense } from 'react';

const SettingsPage = lazy(() => import('./SettingsPage'));

function App() {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <SettingsPage />
    </Suspense>
  );
}

Apply this at the route level — each route gets its own chunk, so users only download code for screens they actually visit. In Next.js, route-level splitting is automatic.

Beyond lazy loading, audit your bundle with webpack-bundle-analyzer or Vite’s rollup-plugin-visualizer. Common culprits for unexpected bundle bloat include:

  • Importing all of lodash (import _ from 'lodash') rather than individual functions
  • Large date libraries like moment.js — consider date-fns or dayjs
  • Duplicate dependencies pulled in by different packages

Keep Urgent Updates Responsive with Transitions

Some updates are urgent. Typing into an input should feel immediate. Other updates are less urgent — filtering a large list or switching a heavy tab can wait slightly if that keeps the interface responsive.

useTransition lets you mark state updates as non-blocking. React will process urgent updates first and defer the transition until time is available:

import { useState, useTransition } from 'react';

function SearchPage({ allItems }) {
  const [input, setInput] = useState('');
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();

  function handleChange(e) {
    const value = e.target.value;
    setInput(value); // urgent — updates immediately

    startTransition(() => {
      setQuery(value); // non-urgent — deferred
    });
  }

  const results = allItems.filter((item) =>
    item.name.toLowerCase().includes(query.toLowerCase()),
  );

  return (
    <>
      <input value={input} onChange={handleChange} />
      {isPending && <p>Updating results...</p>}
      <Results items={results} />
    </>
  );
}

This does not reduce the total amount of work, but it improves perceived responsiveness by ensuring user input never feels blocked.

useDeferredValue gives you similar control when the value comes from outside (a prop or context) rather than your own state update:

const deferredQuery = useDeferredValue(searchQuery);

const results = useMemo(
  () => filterItems(items, deferredQuery),
  [items, deferredQuery],
);

The input updates immediately with searchQuery, but the expensive filtering runs against deferredQuery, which lags slightly behind. The practical effect: the UI stays responsive even when filtering is slow.

One caveat worth knowing: useDeferredValue compares values using Object.is. If you pass a new object created during render — rather than a primitive or a stable reference — React will treat it as changed on every render and schedule unnecessary background re-renders. Primitives like strings and numbers do not have this problem, which is why searchQuery works well here.

Optimize React Context

Every component that consumes a context may re-render whenever the provider’s value changes, even if the component only uses one part of that value.

The most effective fix is to split one large context into focused contexts by concern:

// ❌ One large context — a cart update re-renders everything
const AppContext = createContext({ user, theme, cart });

// ✅ Separate contexts — changes are isolated
const UserContext = createContext(user);
const ThemeContext = createContext(theme);
const CartContext = createContext(cart);

A CartContext update no longer forces components that only read UserContext to re-render.

If you cannot split contexts, at least memoize the value object so it is not recreated on every provider render:

const value = useMemo(() => ({ user, updateUser }), [user, updateUser]);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;

Use Server Components for Data-Heavy UI

If you are building with Next.js’s App Router, React Server Components represent a meaningful architectural shift. Server Components render in a separate environment — at build time or per request — before the component code is bundled for the client. They produce a serialized React tree, not your component’s JavaScript, which means their code never ships to the browser and there is nothing to hydrate.

This makes them ideal for components that fetch data and have no interactivity:

// app/products/page.tsx — runs on the server, zero client JS
async function ProductsPage() {
  const products = await db.query('SELECT * FROM products');

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

Interactive pieces get marked with 'use client' and are hydrated selectively. The result is less client-side JavaScript, which can improve interactivity. Because the data fetch happens on the server, you often avoid client-side loading-state code for that component, though route-level loading UI or Suspense boundaries may still be useful for streaming.

Know Where React Compiler Fits

React Compiler is an optional build-time tool that automatically applies memoization based on its analysis of your components and hooks. It is now stable and available for Babel, Vite, Metro, and Rsbuild. Importantly, it is compatible with React 17 and up — it is not tied to React 19, though some features work best with it.

The compiler can significantly reduce the need for manual memo, useMemo, and useCallback — it catches optimization opportunities that developers often miss or apply incorrectly. But it does not replace good architectural decisions:

  • Avoid unnecessary global state
  • Keep render logic pure and free of side effects
  • Virtualize large lists
  • Split large bundles
  • Avoid expensive work on the main thread

React Compiler can reduce manual memoization work. It cannot fix every architectural problem.

A Few More Quick Wins

Defer non-critical third-party scripts. Analytics, chat widgets, and A/B testing libraries can delay interactivity. Load them after the main bundle using dynamic imports or the defer attribute.

Use image optimization. In Next.js, the <Image> component handles lazy loading, responsive sizing, and modern formats (WebP/AVIF) automatically. Outside of Next.js, add loading="lazy" and decoding="async" to your <img> tags.

Be selective with inline functions and objects in JSX. Inline arrow functions and object literals create new references on every render, which only matters when referential equality matters — passing props to memoized children or values used as hook dependencies. If neither of those applies, an inline function is perfectly fine.

A Practical Optimization Checklist

When a React screen feels slow, work through this order:

  1. Measure first. Use React DevTools Profiler, the <Profiler> component, and Web Vitals in production.
  2. Find the specific interaction. Is it typing, scrolling, opening a modal, or initial load?
  3. Reduce the render scope. Move state down. Split components. Avoid making the whole page re-render for local interactions.
  4. Memoize expensive children. Use React.memo when a child is costly and often receives the same props.
  5. Stabilize props only when needed. Use useMemo for expensive derived values and useCallback for callbacks passed to memoized children.
  6. Virtualize large lists. Do not render thousands of DOM nodes when users can only see 20.
  7. Split code. Use React.lazy and route-level splitting for large or infrequently visited screens.
  8. Use transitions for non-urgent updates. Keep direct user interactions — typing, clicking — immediately responsive.
  9. Verify the improvement. Profile again after the change.

Conclusion

React performance optimization is mostly about reducing unnecessary work. Sometimes that means memoizing a component. Sometimes it means moving state closer to where it is used. Sometimes it means virtualizing a list, splitting a bundle, or marking an update as non-urgent.

The important thing is to avoid treating optimization hooks as default boilerplate. useMemo, useCallback, and React.memo are useful tools, but they are not a substitute for measurement and good component design. Modern React also gives us newer options — transitions, useDeferredValue, Server Components, and React Compiler — but the core principle remains the same: measure first, optimize the actual bottleneck, and keep the code maintainable.