Skip to content
|By Muhammad Wilayat Shah|0 views • 0 unique IPs

React Performance Optimization: Speed Up Your React App

A practical React performance optimization guide: profile first, stop wasted re-renders with memo and context splitting, virtualize long lists, use useTransition and code-split heavy pages.

ShareLinkedInX
React Performance Optimization: Speed Up Your React App

React performance optimization starts with one rule: measure before you change anything. Most slow React apps are not slow because React is slow. They re-render far more components than needed, render thousands of DOM nodes at once, or make the browser download and parse a huge JavaScript bundle before the first click works.

This guide walks through the symptoms, the real root causes, and a step-by-step way to speed up a React app with the React DevTools Profiler, React.memo, useMemo, useCallback, context splitting, list virtualization, code splitting and concurrent features like useTransition. The examples work for plain React (Vite) and Next.js projects.

Problem: Why React Performance Optimization Matters

A typical case: an admin dashboard or ERP-style screen loads orders, filters and charts. It felt fine with 50 rows in development. In production, with 5,000 rows and real users, typing in the search box lags, clicking a filter freezes the tab for a moment, and the first load shows a blank screen for several seconds on mid-range phones.

React updates the screen in two phases: it renders (calls your components to work out what the UI should look like) and then commits (applies the changes to the DOM). When a parent's state changes, React re-renders that parent and, by default, all of its children, even if their props did not change. That is usually cheap. It becomes a problem when the subtree is large, a component does heavy work during render, or the update happens on every keystroke.

Symptoms of a Slow React App

  • Typing in an input lags behind the keyboard, especially in forms with many fields or a live-filtered list.
  • Clicking a tab, filter or checkbox freezes the page briefly.
  • Scrolling a long table or feed stutters.
  • The first load shows a spinner or white screen for a long time, and Lighthouse reports a large amount of unused JavaScript.
  • Chrome DevTools shows long tasks (red-cornered blocks over 50 ms) in the Performance panel.
  • In the console you may see warnings such as [Violation] 'input' handler took 312ms or [Violation] 'click' handler took 480ms.
  • Core Web Vitals report a poor Interaction to Next Paint (INP) or Largest Contentful Paint (LCP).

Root Cause: What Actually Makes React Slow

  1. State lives too high. A search term stored in the top-level page component re-renders the whole page on every keystroke.
  2. New references every render. Inline objects (style={{...}}), arrays and arrow functions passed as props are new on every render, so React.memo children re-render anyway.
  3. One large context. A single context holding user, theme, cart and filters re-renders every consumer when any one value changes.
  4. Expensive work during render. Sorting, filtering or formatting thousands of items inside the component body, on every render.
  5. Too many DOM nodes. Rendering every row of a 5,000-row table instead of only the visible ones.
  6. Unstable keys. Using key={Math.random()} or array index keys on reordered lists forces React to remount items.
  7. Big bundles. Charting libraries, editors and date libraries loaded on the first page even when the user never opens that screen.
  8. Testing the development build. Development mode (and Strict Mode's double rendering) is slower than production; always confirm problems on a production build.
React performance optimization causes and fixes: new props each render, large context, thousands of DOM rows, big bundle, laggy typing
Match what the Profiler shows to the right fix instead of memoizing everything.

Step-by-Step Solution: Speed Up Your React App

1. Profile a production build first

Build and serve the production version, then use the React DevTools Profiler tab: press record, perform the slow interaction, stop. The flame graph shows which components rendered, how long each took, and (with "Record why each component rendered while profiling" enabled in the Profiler settings) why they rendered. Turn on "Highlight updates when components render" to see wasted re-renders live.

Code
# Vite
npm run build && npx vite preview

# Next.js
npm run build && npm run start

2. Move state down, or lift content up

Before reaching for memoization, keep fast-changing state in the smallest component that needs it. If only the search box and the results use query, do not store it in the page component.

Code
// Before: every keystroke re-renders Header, Sidebar and Charts
function OrdersPage() {
  const [query, setQuery] = useState('');
  return (
    <>
      <Header />
      <Sidebar />
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <OrderResults query={query} />
      <Charts />
    </>
  );
}

// After: only OrderSearch re-renders while typing
function OrdersPage() {
  return (
    <>
      <Header />
      <Sidebar />
      <OrderSearch />
      <Charts />
    </>
  );
}

function OrderSearch() {
  const [query, setQuery] = useState('');
  return (
    <>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <OrderResults query={query} />
    </>
  );
}

3. Use React.memo, useMemo and useCallback where the Profiler points

React.memo skips re-rendering a component when its props are shallowly equal. It only helps if the props are stable, which is where useMemo and useCallback come in.

Code
import { memo, useMemo, useCallback, useState } from 'react';

const OrderRow = memo(function OrderRow({ order, onSelect }) {
  return (
    <li onClick={() => onSelect(order.id)}>
      {order.number} - {order.customer} - {order.total}
    </li>
  );
});

function OrderList({ orders, status }) {
  const [selectedId, setSelectedId] = useState(null);

  // Expensive filter + sort only runs when inputs change
  const visible = useMemo(
    () => orders
      .filter(o => status === 'all' || o.status === status)
      .sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
    [orders, status]
  );

  // Same function reference between renders, so memo(OrderRow) can skip
  const handleSelect = useCallback(id => setSelectedId(id), []);

  return (
    <ul>
      {visible.map(o => (
        <OrderRow key={o.id} order={o} onSelect={handleSelect} />
      ))}
    </ul>
  );
}

Do not wrap everything. Memoization has its own cost and makes code harder to read. If your project uses the React Compiler (the babel-plugin-react-compiler plugin, also available as an option in Next.js), it adds much of this memoization automatically, so check the Profiler before adding manual useMemo calls.

4. Split context and avoid new context values

Code
// Before: value is a new object every render, so every consumer re-renders
<AppContext.Provider value={{ user, theme, cart, setCart }}>

// After: separate contexts, memoized values
const cartValue = useMemo(() => ({ cart, setCart }), [cart]);

<UserContext.Provider value={user}>
  <ThemeContext.Provider value={theme}>
    <CartContext.Provider value={cartValue}>
      {children}
    </CartContext.Provider>
  </ThemeContext.Provider>
</UserContext.Provider>

For state that changes very often and is read in many places, a store with selectors (Zustand, Redux Toolkit with useSelector) lets each component subscribe only to the slice it uses.

5. Virtualize long lists and tables

Rendering only the rows that are on screen keeps the DOM small no matter how much data you have:

Code
npm install @tanstack/react-virtual
Code
import { useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';

function VirtualOrders({ orders }) {
  const parentRef = useRef(null);
  const rowVirtualizer = useVirtualizer({
    count: orders.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 44,
    overscan: 8,
  });

  return (
    <div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
      <div style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
        {rowVirtualizer.getVirtualItems().map(item => (
          <div
            key={orders[item.index].id}
            style={{
              position: 'absolute', top: 0, left: 0, width: '100%',
              height: item.size, transform: `translateY(${item.start}px)`,
            }}
          >
            {orders[item.index].number} - {orders[item.index].customer}
          </div>
        ))}
      </div>
    </div>
  );
}

Server-side pagination is often an even better fix for ERP-style screens: send 50 rows from your Laravel or Node API instead of 5,000. If that API is slow itself, check for N+1 query problems before blaming the frontend.

6. Keep typing responsive with useDeferredValue and useTransition

Code
import { useDeferredValue, useState, useTransition } from 'react';

function ProductSearch({ products }) {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);   // results lag slightly, input stays instant
  return (
    <>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <ResultsList products={products} query={deferredQuery} />
    </>
  );
}

function Tabs() {
  const [tab, setTab] = useState('orders');
  const [isPending, startTransition] = useTransition();
  return (
    <>
      <button onClick={() => startTransition(() => setTab('reports'))}>
        Reports {isPending && '...'}
      </button>
      {tab === 'orders' ? <Orders /> : <Reports />}
    </>
  );
}

Wrap ResultsList in memo so it can skip re-rendering while the deferred value catches up.

7. Split the bundle with React.lazy and dynamic import

Code
import { lazy, Suspense } from 'react';

const ReportsPage = lazy(() => import('./pages/ReportsPage'));

function App() {
  return (
    <Suspense fallback={<p>Loading reports...</p>}>
      <ReportsPage />
    </Suspense>
  );
}
Code
// Next.js: load a heavy chart only on the client, only when rendered
import dynamic from 'next/dynamic';

const SalesChart = dynamic(() => import('../components/SalesChart'), {
  ssr: false,
  loading: () => <p>Loading chart...</p>,
});

Note that in the Next.js App Router, ssr: false is only allowed inside a Client Component (a file with 'use client'). Turning a component off for server rendering is also a common way to fix Next.js hydration errors caused by browser-only code.

Code and Commands: Find What to Optimize

Analyze the bundle

Code
# Vite
npm install -D rollup-plugin-visualizer
# vite.config.js: plugins: [react(), visualizer({ open: true })]
npm run build

# Next.js
npm install -D @next/bundle-analyzer
ANALYZE=true npm run build
Code
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({});

Look for large libraries you only use in one place, duplicate copies of the same package, and whole-library imports such as import _ from 'lodash' where import debounce from 'lodash/debounce' would do.

Measure render time in code

Code
import { Profiler } from 'react';

function onRender(id, phase, actualDuration) {
  if (actualDuration > 16) {
    console.warn(`${id} ${phase} took ${actualDuration.toFixed(1)}ms`);
  }
}

<Profiler id="OrderList" onRender={onRender}>
  <OrderList orders={orders} status={status} />
</Profiler>

Track real-user Web Vitals

Code
npm install web-vitals
Code
import { onINP, onLCP, onCLS } from 'web-vitals';

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

Fix effect-driven extra renders

Copying props into state with an effect causes a second render on every change. Derive the value during render instead:

Code
// Before: two renders per change
const [fullName, setFullName] = useState('');
useEffect(() => { setFullName(first + ' ' + last); }, [first, last]);

// After: one render
const fullName = first + ' ' + last;

If your components are full of effects like this, read why too many useEffect hooks are a code smell.

Verification: Confirm the React Optimization Worked

  • Record the same interaction in the React DevTools Profiler before and after; the number of components that rendered and the commit duration should both drop.
  • With "Highlight updates" on, typing in the search box should only flash the input and the results, not the whole page.
  • In the Chrome Performance panel, the long tasks for that interaction should be shorter or gone.
  • Run Lighthouse on the production build and compare LCP, Total Blocking Time and the "Reduce unused JavaScript" audit.
  • Check the bundle analyzer again to confirm heavy libraries moved out of the main chunk.
  • Watch real-user INP from web-vitals or your analytics for a few days after release.

Prevention: Keep Your React App Fast

  • Keep state local and lift it only when two components really share it.
  • Use stable, unique keys from your data, such as database ids.
  • Paginate or virtualize any list that can grow past a few hundred rows.
  • Lazy-load routes and heavy widgets such as charts, rich text editors and maps.
  • Set a bundle budget and check the analyzer in code review when adding dependencies.
  • Profile new screens with realistic data volumes, not 10 test rows.
  • Keep a clear folder and component structure so state ownership stays obvious; see how to structure a large React project.

FAQs

What is the best way to start React performance optimization?

Build for production, record the slow interaction in the React DevTools Profiler, and fix the components that take the most time or re-render without reason. Guessing usually leads to memoizing code that was never slow.

How do I speed up a React app that re-renders too much?

Move fast-changing state closer to where it is used, split large contexts, pass stable props with useMemo and useCallback, and wrap expensive children in React.memo.

Should I use useMemo and useCallback everywhere?

No. They add overhead and complexity. Use them for expensive calculations and for props passed to memoized children. The React Compiler can handle much of this automatically.

Does React optimization help initial load time?

Render optimizations mostly help interactions. For initial load, reduce JavaScript with code splitting, lazy loading and smaller dependencies, and in Next.js render more on the server.

Why is my React app slow only in development?

The development build includes extra checks and warnings, and Strict Mode renders components twice to find bugs. Measure performance on a production build.

How many rows can React render before it gets slow?

It depends on how heavy each row is and the user's device. If scrolling or updates feel slow with a long list, virtualize it or paginate on the server instead of tuning each row.

Conclusion

Good React performance optimization is a loop: profile, find the slowest or most wasted renders, apply the smallest fix that works, then verify. Keep state local, stabilize props for memoized children, split contexts, virtualize big lists, defer non-urgent updates, and ship less JavaScript with code splitting. Do that on every new screen and your React or Next.js app stays fast as your data grows.

ShareLinkedInX
Back to blog

Keep reading

SSH Permission Denied (Publickey): How to Fix It

10/21/2026

SSH Permission Denied (Publickey): How to Fix It

Fix SSH permission denied (publickey) errors on GitHub, GitLab and Linux servers: debug with ssh -v, load the right key, set 700/600 permissions, check sshd_config and CI/CD deploy keys.

502 Bad Gateway Nginx: Causes and How to Fix It

10/21/2026

502 Bad Gateway Nginx: Causes and How to Fix It

Fix 502 Bad Gateway in Nginx step by step: read error.log, then solve connection refused, PHP-FPM socket and permission errors, crashed workers, big headers and Docker upstream issues.

Newsletter

Occasional notes on Laravel, React, and shipping production systems — no spam.

Comments

Join the discussion. Replies load on demand, up to 5 levels.

0 threads

Loading editor…
Reviewed before publishing

No comments yet

Be the first to start the conversation.