Blog

Blog · Performance & Optimization

How to Optimize React Performance in 2026: 14 Techniques

Practical React performance techniques — the React Compiler, stable keys, list virtualization, useTransition/useDeferredValue, code splitting, Server Components and streaming SSR — each with why it matters and a code example.

XCODX Team · 8 min read

React apps get slow in a handful of predictable ways: too many re-renders, expensive work on every keystroke, giant lists, and shipping too much JavaScript to the client. The good news is each has a well-understood fix. Here are 14 React performance techniques for 2026 — including what changes now that the React Compiler handles memoization for you — each with the reasoning and a code example.

1. Let the React Compiler memoize for you

The React Compiler (v1.0, stable in 2025) analyzes your components at build time and automatically memoizes them and their values — eliminating most re-renders caused by new object/function references. In practice it removes the need for the bulk of hand-written useMemo, useCallback and React.memo. Your components must follow the Rules of React (be pure, no mutation during render) for it to safely kick in; the ESLint plugin flags violations.

// Install and enable via the Babel/SWC/Vite plugin
npm install babel-plugin-react-compiler

// babel.config.js
module.exports = {
  plugins: [["babel-plugin-react-compiler", { target: "19" }]],
};
// Now most manual useMemo/useCallback/memo can be deleted.

2. Use stable, unique keys — never the array index

Keys tell React which list items are the same across renders. Using the array index as a key means that inserting, removing or reordering items makes React reuse the wrong DOM nodes — causing lost input state, wrong animations and subtle bugs, plus extra work. Use a stable id from your data instead.

// ❌ index keys break on reorder/insert
{items.map((item, i) => <Row key={i} {...item} />)}

// ✅ stable id from the data
{items.map((item) => <Row key={item.id} {...item} />)}

3. Keep state local and colocate it

State placed too high re-renders the whole subtree on every change. Move state down to the smallest component that needs it (colocation), so a change only re-renders that branch. A classic example: a controlled input at the top of a page re-renders the entire page on each keystroke — push it into a small child.

// ❌ page-level state re-renders everything on each keystroke
function Page() {
  const [q, setQ] = useState("");
  return <><Search value={q} onChange={setQ} /><HugeList /></>;
}

// ✅ isolate the input so HugeList is untouched
function Page() { return <><Search /><HugeList /></>; }

4. Split contexts so consumers don't all re-render

Every consumer of a Context re-renders when any part of its value changes. Putting unrelated state (theme + user + cart) in one context makes a cart update re-render theme consumers. Split into focused contexts, and keep the value referentially stable (the Compiler helps here) so consumers only react to what they use.

// ❌ one mega-context — any change re-renders every consumer
<AppContext.Provider value={{ theme, user, cart }}>

// ✅ focused providers
<ThemeContext.Provider value={theme}>
  <CartContext.Provider value={cart}>

5. Virtualize long lists and tables

Rendering thousands of DOM nodes is slow to mount and to update. List virtualization renders only the rows in (and near) the viewport, keeping the DOM small regardless of data size — often the difference between a janky and an instant table. Use a maintained library like TanStack Virtual, and set a sensible overscan.

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

const v = useVirtualizer({
  count: rows.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 40,
  overscan: 8,
});
// render only v.getVirtualItems() instead of all rows

6. Mark non-urgent updates with useTransition

When a state update triggers expensive rendering (filtering a big list, switching a heavy tab), wrapping it in startTransition marks it as interruptible — React keeps the UI responsive to typing/clicks and renders the heavy update in the background. isPending lets you show a subtle loading state without blocking input.

const [isPending, startTransition] = useTransition();

function onChange(e) {
  setText(e.target.value);            // urgent: keep the input snappy
  startTransition(() => setQuery(e.target.value)); // non-urgent heavy filter
}

7. Keep inputs responsive with useDeferredValue

useDeferredValue lets an expensive child lag behind a fast-changing value: the input updates immediately while the heavy list re-renders with the deferred value, so typing never stutters. It’s the drop-in choice when you have a value (not a setState call) driving expensive rendering.

const deferredQuery = useDeferredValue(query);
// Results re-renders with deferredQuery; the input stays instant
const results = useMemo(() => filter(data, deferredQuery), [deferredQuery]);

8. Code-split routes with lazy() and Suspense

Don’t ship the whole app on first load. React.lazy() + Suspense splits each route into its own chunk that loads on demand, cutting the initial JavaScript dramatically. Give every lazy boundary a fallback, and prefetch the next likely route on hover/intent so navigation still feels instant.

const Dashboard = lazy(() => import("./routes/Dashboard"));

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

9. Reach for useMemo/useCallback only where the Compiler can't help

If you haven’t adopted the Compiler yet, memoize genuinely expensive computations with useMemo and stabilize callbacks passed to memoized children with useCallback. Don’t memoize everything — memoization has its own cost, and wrapping trivial values makes code slower and harder to read. Measure first.

// worth memoizing: an expensive derived value
const sorted = useMemo(() => bigArray.slice().sort(compare), [bigArray]);

// worth stabilizing: a callback passed to a memoized child
const onSelect = useCallback((id) => setSelected(id), []);

10. Avoid creating new objects/functions in render (pre-Compiler)

Without the Compiler, passing a fresh inline object or arrow function as a prop gives a memoized child a new reference every render, defeating React.memo. Hoist stable values out, or pass primitives. (The React Compiler makes this a non-issue — another reason to adopt it.)

// ❌ new object + new function every render break memo
<Child style={{ margin: 8 }} onClick={() => go(id)} />

// ✅ stable references
const style = { margin: 8 };            // module scope
const onClick = useCallback(() => go(id), [id]);

11. Ship less JavaScript with React Server Components

Server Components render on the server and send no component JavaScript to the browser — only the resulting UI — so data-fetching and heavy, non-interactive parts of the tree add zero bundle weight. Keep "use client" boundaries small and push interactivity to the leaves. Frameworks like Next.js (App Router) ship RSC by default in 2026.

// server component (default in the App Router): no client JS shipped
export default async function Page() {
  const posts = await db.posts.findMany();   // runs on the server
  return <PostList posts={posts} />;
}
// add "use client" only to the interactive leaves that need it

12. Stream SSR with Suspense

Streaming server rendering sends HTML in chunks as it’s ready instead of waiting for the whole page, so users see and interact with the shell immediately while slower sections stream in. Wrapping slow parts in Suspense lets the fast content paint first — a big Time-to-First-Byte and LCP win for content-heavy pages.

<Suspense fallback={<HeaderSkeleton />}>
  <SlowHeader />           {/* streams in when ready */}
</Suspense>
<Suspense fallback={<FeedSkeleton />}>
  <Feed />                 {/* the shell paints without waiting for this */}
</Suspense>

13. Avoid unnecessary effects; derive during render

A common performance and correctness trap is using useEffect to compute state from props — it causes an extra render pass and can flicker. If a value can be calculated from existing props/state, compute it during render instead of storing it in state and syncing with an effect. Reserve effects for actual external side effects (subscriptions, network, DOM).

// ❌ extra render + state you must keep in sync
const [full, setFull] = useState("");
useEffect(() => setFull(first + " " + last), [first, last]);

// ✅ derive during render — no effect, no extra render
const full = first + " " + last;

14. Profile with the React DevTools Profiler

Don’t guess — record an interaction in the React DevTools Profiler to see which components rendered, how often and why (it flags props/state/hooks changes). Combine it with the browser Performance panel for long tasks. Measure, fix the biggest offender, re-measure — the 20% of components causing 80% of the cost are usually obvious once recorded.

// React DevTools → Profiler tab → record an interaction.
// Turn on "Highlight updates" to see re-renders live,
// and "Record why each component rendered" to find the cause.

React performance checklist

  • Adopt the React Compiler; delete hand-written memoization it makes redundant.
  • Use stable, unique keys — never the array index for dynamic lists.
  • Colocate state; split contexts so unrelated updates don’t cascade.
  • Virtualize long lists/tables; keep the DOM small.
  • Mark heavy updates with useTransition; keep inputs snappy with useDeferredValue.
  • Code-split routes with lazy() + Suspense; prefetch on intent.
  • Ship less JS with Server Components; stream SSR with Suspense.
  • Derive values during render instead of syncing with effects; profile before optimizing.

Frequently asked questions

Do I still need useMemo and useCallback in 2026?
Much less. The React Compiler (stable v1.0) auto-memoizes components and values at build time, removing most manual useMemo, useCallback and React.memo. If you haven’t adopted it, keep memoizing genuinely expensive computations and callbacks passed to memoized children — but measure first, since needless memoization has its own cost.
Why is my React list slow?
Two usual causes: using the array index as the key (which makes React reuse the wrong DOM nodes on reorder/insert) and rendering thousands of nodes at once. Use a stable id as the key and virtualize the list so only the visible rows are in the DOM.
What’s the difference between useTransition and useDeferredValue?
Both keep the UI responsive during expensive updates. useTransition wraps a state *update* you control (startTransition(() => setState(...))) and gives you isPending. useDeferredValue wraps a *value* you receive and lets a heavy child lag behind it. Use useTransition when you trigger the update, useDeferredValue when a fast-changing prop drives the expensive render.
How do Server Components improve performance?
React Server Components render on the server and send only their resulting UI to the browser — no component JavaScript — so non-interactive, data-heavy parts of your tree add zero bundle weight. Keep "use client" boundaries small and push interactivity to the leaves to ship the least JS.

Prototype and profile React components live in XCODX Studio — with npm packages in the browser, no local setup. See also our React best practices, How to Reduce Bundle Size and Code Splitting Guide.