The ticket says “the search box feels laggy”. You open React DevTools, turn on highlight updates, type one letter, and the whole page flashes. The reflex is to wrap everything in memo. Resist it. Most React performance problems come from a few structural causes that have nothing to do with memoization, and React Compiler 1.0 now writes most of the memoization for you. Below: how to measure re-renders, the four usual causes, the fixes that beat memo, when useMemo and useCallback still help, and how to turn on the compiler in Next.js 16 or Vite.
What a re-render is, and when it actually hurts
A re-render is React calling your component function again to get fresh JSX. It doesn’t mean the DOM changed: React compares the new output with the old one and only commits the differences. A component re-renders when:
its own state changes,
its parent re-renders, or
a context it reads changes.
“Its props changed” isn’t on the list. A child re-renders because its parent rendered, whether its props changed or not. memo is the thing that adds a props check.
Most re-renders are harmless. They hurt on a hot path, like every keystroke, scroll event or pointer move, running through an expensive subtree or a long list. That’s the lag users feel, and it shows up as a poor Interaction to Next Paint score.
How to measure re-renders before fixing anything
1. Highlight updates
In React DevTools, open the settings (gear icon) and tick “Highlight updates when components render.” Then type, scroll and click. It shows the scope of a re-render, not its cost: a flashing component can be perfectly cheap.
2. The Profiler, with “why did this render”
In the Profiler tab’s settings, tick “Record why each component rendered while profiling.” Then record, do the slow interaction, and stop. The Flamegraph shows each commit’s tree with render times, and Ranked sorts components by cost. Click one and the sidebar answers “Why did this render?”: props, state or hooks changed, or the parent rendered.
Profile something close to production: development builds are slower, and Strict Mode renders twice. In Next.js, next build --profile enables React’s production profiling. Use Chrome’s CPU throttling too, because your laptop is faster than your users’ phones.
3. React Performance Tracks in Chrome DevTools
React 19.2 added custom tracks to the Chrome DevTools Performance panel. The Scheduler track shows what React is working on at each priority (blocking for input, transition for startTransition updates). The Components track shows which components rendered or ran effects, and for how long. Record a normal performance trace in a development or profiling build and the React tracks appear next to the browser’s own. It’s the quickest way to see whether a render is blocking input.
Why did my component re-render? The four usual causes
1. State lives too high. A newsletter input’s state sits in the page component, so every keystroke re-renders the whole page, including a 200-card product grid that never reads it.
2. Unstable props. Inline objects, arrays and functions are new on every render. memo compares each prop with Object.is (see the memo docs), so a new { padding: 8 } counts as “changed” every time:
{products.map((p) => (
// New object + new function every render: memo can't skip
<Row
key={p.id}
product={p}
style={{ padding: 8 }}
onSelect={() => select(p.id)}
/>
))}I tested exactly this with React 19.3: three memoized rows, three unrelated parent updates, 12 row renders. memo did nothing except add a comparison.
3. Context value churn. Every component that reads a context re-renders when its value changes, and memo doesn’t stop that. A provider that builds a fresh value object each render, or mixes fast and slow-changing state, re-renders every consumer on each update. Keep fast state out of wide contexts.
4. Big lists. A cheap row is still expensive two thousand times. Bad keys make it worse (more on that below).
Fixes that beat memoization
These take the expensive parts out of the re-render path entirely, with or without the compiler.
Move state down
Take the page from cause 1 and extract the input, with its state, into a component of its own:
// components/ProductPage.tsx (after)
"use client";
import { useState } from "react";
import { ProductGrid } from "./ProductGrid";
import type { Product } from "./types";
function NewsletterInput() {
const [email, setEmail] = useState("");
return <input value={email} onChange={(e) => setEmail(e.target.value)} />;
}
export function ProductPage({ products }: { products: Product[] }) {
return (
<>
<ProductGrid products={products} />
<NewsletterInput />
</>
);
}Now a keystroke re-renders NewsletterInput and nothing else. In my test, the grid went from one render per keystroke to none after the first mount. The same move works inside one big component: if one corner ticks (a countdown, a live price, a “saving…” label), give that corner its own component and state.
Lift content up: pass children
When a wrapper needs state (scroll position, open/closed, hover), let it take the expensive part as children:
// components/ScrollHeader.tsx
"use client";
import { useEffect, useState, type ReactNode } from "react";
export function ScrollHeader({ children }: { children: ReactNode }) {
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 80);
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
return <header data-scrolled={scrolled}>{children}</header>;
}// app/blog/page.tsx
import { ScrollHeader } from "@/components/ScrollHeader";
import { ExpensiveArticle } from "@/components/ExpensiveArticle";
export default function Page() {
// Created here, so ScrollHeader's state changes don't re-render it
return (
<ScrollHeader>
<ExpensiveArticle />
</ScrollHeader>
);
}The <ExpensiveArticle /> element is created by Page, not by ScrollHeader. When ScrollHeader’s state changes, React gets the same children element as last time and skips it. In my test, the child rendered once across three wrapper updates.
Use stable keys
Keys tell React which item is which between renders. key={Math.random()} means keys never match, so React recreates every component and DOM node on every render and loses input state. Use an ID from your data. Index keys are only safe for lists that never reorder, insert or delete.
Most slow re-renders are a state-placement problem, not a missing memo.
When memo, useMemo and useCallback still help
Reach for them after the Profiler points at something specific:
memowhen a component is measurably expensive and often re-renders with the same props. It only works if those props are stable.useMemowhen a calculation is slow and its inputs rarely change. React’s docs suggest timing it withconsole.time; if it adds up to around 1 ms or more, memoizing can pay off. It never makes the first render faster.useCallbackwhen a function goes to a memoized child or into an Effect’s dependency array.
Here’s the fixed version of the list from earlier: a hoisted style object and one stable callback.
// components/ProductList.tsx
"use client";
import { memo, useCallback, useState, type CSSProperties } from "react";
import type { Product } from "./types";
type RowProps = {
product: Product;
style: CSSProperties;
onSelect: (id: string) => void;
};
const Row = memo(function Row({ product, style, onSelect }: RowProps) {
return (
<li style={style} onClick={() => onSelect(product.id)}>
{product.name}
</li>
);
});
const rowStyle = { padding: 8 }; // created once, not per render
export function ProductList({ products }: { products: Product[] }) {
const [selected, setSelected] = useState<string | null>(null);
const handleSelect = useCallback((id: string) => setSelected(id), []);
return (
<ul data-selected={selected}>
{products.map((p) => (
<Row key={p.id} product={p} style={rowStyle} onSelect={handleSelect} />
))}
</ul>
);
}How React Compiler fixes re-renders automatically
React Compiler shipped its stable 1.0 release on 7 October 2025, and babel-plugin-react-compiler@1.0.0 is still the latest version on npm in October 2026. It’s a build-time Babel plugin that adds memoization to your components and hooks: computed values, functions and JSX, with dependencies worked out for you. It supports React 17, 18 and 19.
I ran the unstable-props list from earlier, unchanged, through the compiler. Row renders dropped from 12 to 3, one per row at mount. The inline object and arrow function were cached, so memo finally had stable props to compare. Know its limits, per the compiler docs: it only memoizes components and hooks, and its memoization isn’t shared across components. A context consumer still re-renders when the value really changes.
Enable it in Next.js 16
npm install -D babel-plugin-react-compiler// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactCompiler: true,
};
export default nextConfig;reactCompiler became a stable top-level option in Next.js 16.0.0 (it used to be experimental.reactCompiler). Next.js uses an SWC pre-pass so the Babel plugin only runs on files with JSX or hooks. New apps can start with npx create-next-app@latest --react-compiler. To roll it out gradually, set reactCompiler: { compilationMode: "annotation" } and opt components in with a "use memo" directive.
Enable it with Babel or Vite
With plain Babel, add babel-plugin-react-compiler as the first plugin, because it needs the original source. With @vitejs/plugin-react 6 or later, the inline babel option is gone. Use the preset with @rolldown/plugin-babel:
// vite.config.js
import { defineConfig } from "vite";
import react, { reactCompilerPreset } from "@vitejs/plugin-react";
import babel from "@rolldown/plugin-babel";
export default defineConfig({
plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
});To confirm it’s working, look for the Memo ✨ badge next to component names in React DevTools.
The Rules of React it relies on, and the ESLint plugin
The compiler assumes your code follows the Rules of React: components and hooks are pure (no side effects during render, no mutating props, state or hook arguments), React calls your components (don’t call them like functions), and hooks are called at the top level. When it can’t prove a component is safe, it skips that component silently. This one compiled to exactly its input, with no memoization and no warning:
// components/SearchResults.tsx
let lastQuery = "";
export function SearchResults({ query }: { query: string }) {
lastQuery = query; // writes a module variable during render
return <p>Results for {query}</p>;
}The lint rules catch this. In eslint-plugin-react-hooks 7, the compiler’s checks are part of the recommended preset, so you no longer need a separate eslint-plugin-react-compiler:
// eslint.config.js
import reactHooks from "eslint-plugin-react-hooks";
import { defineConfig } from "eslint/config";
export default defineConfig([reactHooks.configs.flat.recommended]);On the component above it reports Cannot reassign variables declared outside of the component/hook (react-hooks/globals). It flags key={Math.random()} too, as an impure call during render. Fix what it reports and those components get compiled.
The "use no memo" escape hatch
If a compiled component misbehaves, opt it out while you investigate:
// components/LegacyChart.tsx
import { drawChart, type Point } from "./chart-lib";
export function LegacyChart({ data }: { data: Point[] }) {
"use no memo"; // TODO: remove when chart-lib stops mutating props
return <figure>{drawChart(data)}</figure>;
}The directive must be the first statement in the function body, and React’s docs describe it as a temporary debugging tool, not a permanent fix.
Virtualize long lists
No amount of memoization makes 5,000 rendered rows cheap. Virtualization renders only the rows in view, plus a few for overscan. With react-window 2:
// components/OrderList.tsx
"use client";
import { List, type RowComponentProps } from "react-window";
import type { Order } from "./types";
function OrderRow({
index,
style,
ariaAttributes,
orders,
}: RowComponentProps<{ orders: Order[] }>) {
const order = orders[index];
return (
<div style={style} {...ariaAttributes}>
{order.customer}: Rs {order.total}
</div>
);
}
export function OrderList({ orders }: { orders: Order[] }) {
return (
<List
rowComponent={OrderRow}
rowCount={orders.length}
rowHeight={48}
rowProps={{ orders }}
style={{ height: 600 }}
/>
);
}For long, mostly static content, the CSS alternative is content-visibility: auto with a contain-intrinsic-size placeholder: the browser skips rendering off-screen sections but keeps them in the DOM for find-in-page. It’s been Baseline since September 2024.
useTransition and useDeferredValue for renders you can’t avoid
Sometimes the render is real work, like filtering 5,000 items per keystroke. Then the goal is keeping input responsive. useTransition marks a state update as non-urgent, and the Core Web Vitals guide walks through it for a search box. useDeferredValue does the same from the reading side:
// components/Search.tsx
"use client";
import { memo, useDeferredValue, useState } from "react";
const Results = memo(function Results({ query }: { query: string }) {
return <p>{query}</p>; // imagine an expensive list here
});
export function Search() {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<Results query={deferredQuery} />
</>
);
}The input updates immediately, and Results catches up when React has time. It needs a memoized child (memo, or the compiler’s equivalent). Unlike a debounce, there’s no fixed delay to tune, and the deferred render can be interrupted.
React performance mistakes I see in code review
memoon everything, inline props everywhere. You pay for the comparison and skip nothing.Optimizing renders that cost 0.1 ms. If the Profiler doesn’t show it, it isn’t your problem.
Turning on the compiler and skipping the linter. The components that break the rules are the ones it leaves unoptimized.
Frequently Asked Questions
Why does my React component re-render when its props haven’t changed?
A React component re-renders whenever its parent re-renders, whether or not its props changed. Props are only compared if the component is wrapped in memo, which checks each prop with Object.is. With React Compiler enabled, that memoization is applied automatically to components that follow the Rules of React.
Does React Compiler replace useMemo and useCallback?
For new code, React’s docs recommend relying on React Compiler for memoization and using useMemo and useCallback only where you need precise control, for example a value used as an Effect dependency. For existing code, leave current memoization in place or test carefully before removing it, because removal can change the compiler’s output.
How do I find what is causing re-renders in React?
Turn on “Highlight updates when components render” in React DevTools to see which components re-render, then record with the Profiler after enabling “Record why each component rendered while profiling”. On React 19.2 and later, the React tracks in the Chrome DevTools Performance panel show render timing and priority in the same trace as the browser’s work.
Is React.memo bad for performance?
React.memo isn’t bad for performance, but it’s wasted when the props it compares are new objects or functions on every render. Use it on components the Profiler shows are expensive, with stable props. With React Compiler on, new components usually don’t need it, but test before removing it from existing code.
The short version
Measure with highlight updates and the Profiler. Fix structure first: move state down, pass children, split components and use stable keys. Then let React Compiler memoize, with the ESLint rules keeping components compilable. Virtualize long lists and defer the renders you can’t avoid. For the network and rendering side of speed, the Next.js performance guide picks up where this leaves off, and the frontend category has more on React in practice.



