DEV Community

Cover image for React Re-Rendering & Performance Mastery (2026 Edition)
Akshay Kurve
Akshay Kurve

Posted on

React Re-Rendering & Performance Mastery (2026 Edition)

A Complete 4-Part Dev.to Series — Updated for React 19 & React Compiler 1.0


Series Navigation

Part Title Focus
Part 1 Why React Re-renders Core concepts, Virtual DOM, Reconciliation
Part 2 Preventing Re-renders memo, useMemo, useCallback, patterns
Part 3 Forms Optimization RHF, controlled vs uncontrolled, Zod
Part 4 React Compiler & Profiling 2026 automation, profiling, checklist

PART 1 — Why React Re-renders, the Rendering Lifecycle, Virtual DOM & Reconciliation

Target reader: Junior to mid-level React developer. No fluff. Just the core mental model you need.


The Mental Model First

Before any code — understand this one sentence:

React re-renders a component whenever it thinks something might have changed.

That word "thinks" is doing a lot of work. React doesn't actually know if your output changed — it just re-runs your function and compares the result. That comparison process is called reconciliation, and the layer it works on is called the Virtual DOM.

Everything else in this series flows from that one idea.


Rendering vs Re-rendering — They're Not the Same

People use these words interchangeably but they mean different things:

Term What it means
Mount Component appears in the tree for the first time
Render React calls your component function and gets JSX back
Commit React applies the diff to the real DOM
Re-render React calls your component function again after initial mount
Unmount Component is removed from the tree
Your Component Function
        │
        ▼
   React calls it  ──► produces JSX (Virtual DOM nodes)
        │
        ▼
 React diffs old vs new Virtual DOM  ──► finds the delta
        │
        ▼
 React updates the real DOM only where needed (Commit phase)
Enter fullscreen mode Exit fullscreen mode

Key insight: Rendering does not equal a DOM update. React can re-render your component 10 times and touch zero DOM nodes if the output didn't change.


The Virtual DOM — What It Actually Is

The Virtual DOM is not magic. It's a plain JavaScript object tree that describes what the UI should look like.

// Your JSX
<div className="card">
  <h1>Hello</h1>
  <p>World</p>
</div>
Enter fullscreen mode Exit fullscreen mode

Gets compiled by Babel/esbuild into this (simplified):

{
  type: "div",
  props: { className: "card" },
  children: [
    { type: "h1", props: {}, children: ["Hello"] },
    { type: "p",  props: {}, children: ["World"] }
  ]
}
Enter fullscreen mode Exit fullscreen mode

That's it. A JavaScript object. React holds two copies — the current tree (what's on screen) and the work-in-progress tree (what's being computed). The process of comparing them is reconciliation.


Reconciliation — The Diffing Algorithm

React's reconciliation has a few core rules:

Rule 1: Different type = destroy and rebuild

// Before
<div><Counter /></div>

// After
<span><Counter /></span>
Enter fullscreen mode Exit fullscreen mode

Because the parent changed from div to span, React tears down the entire subtree and rebuilds it. Your Counter loses all its state.

Rule 2: Same type = update in place

// Before
<Button color="blue" />

// After
<Button color="red" />
Enter fullscreen mode Exit fullscreen mode

React keeps the Button component alive and just updates the color prop. No remount. State is preserved.

Rule 3: Lists need keys

// Bad — React has to guess what changed
{items.map(item => <Item label={item.label} />)}

// Good — React knows exactly what changed
{items.map(item => <Item key={item.id} label={item.label} />)}
Enter fullscreen mode Exit fullscreen mode

Without key, React re-renders every list item whenever any item changes. With a stable key, it surgically updates only the changed node.


When Does React Actually Re-render?

React re-renders a component when any of these four things happen:

1. State changes       → useState / useReducer setter is called
2. Props change        → parent passes new value
3. Context changes     → a Provider value updates
4. Parent re-renders   → child re-renders by default (even if props didn't change)
Enter fullscreen mode Exit fullscreen mode

That last one surprises people most. Let's prove it:

function Parent() {
  const [count, setCount] = useState(0);

  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>Click</button>
      <Child />  {/* No props passed — but this re-renders anyway */}
    </>
  );
}

function Child() {
  console.log("Child rendered"); // fires every time Parent re-renders
  return <p>I am the child</p>;
}
Enter fullscreen mode Exit fullscreen mode

This is by design, not a bug. React assumes that if the parent re-ran, its children might produce different output. Part 2 covers how to stop this when it's unnecessary.


Common Misconceptions

Misconception Reality
"Re-rendering is slow" It's usually fast. Only unnecessary frequent re-renders on heavy components cause real problems
"Virtual DOM makes React fast" The Virtual DOM adds overhead. React is fast because it batches and minimizes DOM writes
"useEffect runs on every render" Only if you pass [] incorrectly or omit the dependency array
"React.memo prevents all re-renders" Only for prop changes. State changes inside the component still cause re-renders
"useMemo makes everything faster" Memoization has a cost. Over-using it can make things slower

The React Rendering Pipeline

graph TD
    A[State / Props / Context Change] --> B[Schedule Re-render]
    B --> C[Render Phase: Call component function]
    C --> D[Produce new Virtual DOM tree]
    D --> E[Reconciliation: Diff old vs new tree]
    E --> F{Any changes?}
    F -- No --> G[Skip DOM update]
    F -- Yes --> H[Commit Phase: Update real DOM]
    H --> I[Run useLayoutEffect]
    I --> J[Browser paints]
    J --> K[Run useEffect]
Enter fullscreen mode Exit fullscreen mode

Part 1 Summary

  • React always re-renders on state/prop/context/parent changes
  • Re-rendering does not equal a DOM update — reconciliation filters out no-ops
  • The Virtual DOM is a JS object tree, not magic
  • Keys are critical for list performance
  • Most re-renders are harmless — profile before you optimize

Next: Part 2 — Preventing Unnecessary Re-renders


PART 2 — Preventing Unnecessary Re-renders: memo, useMemo, useCallback, State Colocation & More

When you've identified a real performance problem (after profiling), these are your tools.


The Golden Rule

Don't optimize what you haven't measured.

React DevTools Profiler (covered in Part 4) will show you which components re-render and how long they take. Optimize only what the profiler tells you is hot.


React.memo — Memoize a Whole Component

React.memo wraps a component and tells React: "Only re-render this if its props actually changed."

// Without memo — re-renders every time Parent does
function UserCard({ user }) {
  return <div>{user.name}</div>;
}

// With memo — only re-renders when `user` prop changes (shallow compare)
const UserCard = React.memo(function UserCard({ user }) {
  return <div>{user.name}</div>;
});
Enter fullscreen mode Exit fullscreen mode

When it works:

  • Component is heavy to render
  • It's a leaf component (doesn't have many children)
  • Props are primitives or stable references

When it breaks:

// This defeats memo every time — new function reference on every parent render
<UserCard onClick={() => handleClick(user.id)} />

// Fix: stabilize the callback with useCallback
const handleClick = useCallback((id) => doSomething(id), []);
<UserCard onClick={handleClick} />
Enter fullscreen mode Exit fullscreen mode

useMemo — Memoize an Expensive Value

// Recalculates on every render — even if products didn't change
const filtered = products.filter(p => p.inStock && p.price < budget);

// Only recalculates when products or budget changes
const filtered = useMemo(
  () => products.filter(p => p.inStock && p.price < budget),
  [products, budget]
);
Enter fullscreen mode Exit fullscreen mode

When to use useMemo:

  • Computations that take over ~1ms (sorting/filtering large arrays, heavy math)
  • Creating stable object references passed as props to memoized children
  • As a dependency of useEffect when object identity matters

When NOT to use useMemo:

// Over-engineered — this is actually slower due to memo overhead
const greeting = useMemo(() => `Hello, ${name}!`, [name]);

// Just do this
const greeting = `Hello, ${name}!`;
Enter fullscreen mode Exit fullscreen mode

useCallback — Memoize a Function Reference

Functions are recreated on every render. When passed as props, they break React.memo because the reference is always "new".

// New reference every render — breaks UserCard's memo
function Parent() {
  const handleDelete = (id) => deleteUser(id);
  return <UserCard onDelete={handleDelete} />;
}

// Stable reference — UserCard's memo works correctly
function Parent() {
  const handleDelete = useCallback((id) => deleteUser(id), []);
  return <UserCard onDelete={handleDelete} />;
}
Enter fullscreen mode Exit fullscreen mode

useRef — Stable Values Without Re-rendering

useRef holds a mutable value that persists between renders but doesn't trigger a re-render when it changes.

function Timer() {
  const intervalRef = useRef(null); // updating this doesn't cause re-render

  useEffect(() => {
    intervalRef.current = setInterval(() => tick(), 1000);
    return () => clearInterval(intervalRef.current);
  }, []);
}
Enter fullscreen mode Exit fullscreen mode

Also useful for reading the previous value of a prop or state:

function Component({ value }) {
  const prevValue = useRef(value);

  useEffect(() => {
    prevValue.current = value;
  });

  return <p>Prev: {prevValue.current}, Current: {value}</p>;
}
Enter fullscreen mode Exit fullscreen mode

State Colocation — The Underrated Optimization

Lifting state too high forces entire subtrees to re-render. Keep state as close to where it's used as possible.

// Search state in App re-renders Header and Sidebar unnecessarily
function App() {
  const [search, setSearch] = useState("");

  return (
    <div>
      <Header />         {/* Re-renders unnecessarily */}
      <Sidebar />        {/* Re-renders unnecessarily */}
      <SearchBar search={search} onChange={setSearch} />
      <Results search={search} />
    </div>
  );
}

// Search state colocated — only SearchBar and Results re-render
function App() {
  return (
    <div>
      <Header />   {/* Never re-renders due to search */}
      <Sidebar />  {/* Never re-renders due to search */}
      <SearchSection />
    </div>
  );
}

function SearchSection() {
  const [search, setSearch] = useState("");
  return (
    <>
      <SearchBar search={search} onChange={setSearch} />
      <Results search={search} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Component Composition — Children as a Slot Pattern

This pattern avoids unnecessary re-renders without any memoization:

// SlowChild re-renders whenever App re-renders
function App() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      <SlowChild />
    </div>
  );
}

// SlowChild is passed as children — App doesn't own it
function Counter({ children }) {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      {children}
    </div>
  );
}

function App() {
  return (
    <Counter>
      <SlowChild /> {/* Created in App's scope — not re-rendered when Counter updates */}
    </Counter>
  );
}
Enter fullscreen mode Exit fullscreen mode

This works because SlowChild is created in App's render scope, not Counter's. When Counter's state changes, React re-runs Counter but children is already a stable prop passed in from App.


Context Optimization

Context is powerful but easy to misuse. Every consumer re-renders when the context value changes — even if only one property changed.

// Every consumer re-renders on any context change
const AppContext = createContext();
function App() {
  const [theme, setTheme] = useState("dark");
  const [user, setUser] = useState(null);
  return (
    <AppContext.Provider value={{ theme, setTheme, user, setUser }}>
      <Everything />
    </AppContext.Provider>
  );
}

// Split contexts by concern — consumers only re-render for their data
const ThemeContext = createContext();
const UserContext = createContext();

function App() {
  const [theme, setTheme] = useState("dark");
  const [user, setUser] = useState(null);
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <UserContext.Provider value={{ user, setUser }}>
        <Everything />
      </UserContext.Provider>
    </ThemeContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Always memoize the context value to prevent a new object reference on every render:

const value = useMemo(() => ({ theme, setTheme }), [theme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
Enter fullscreen mode Exit fullscreen mode

Keys in Lists — Avoid Index as Key

// Using index as key causes remount bugs when list order changes
{todos.map((todo, index) => <TodoItem key={index} todo={todo} />)}

// Use a stable unique ID
{todos.map(todo => <TodoItem key={todo.id} todo={todo} />)}
Enter fullscreen mode Exit fullscreen mode

Using index as key causes React to reuse the wrong DOM element when you sort, filter, or prepend to a list — leading to visual glitches and lost state.


Industry Example — Dashboard Card List

// Before: Every card re-renders on any dashboard state change
function Dashboard() {
  const [filter, setFilter] = useState("all");
  const [sortBy, setSortBy] = useState("name");
  const data = useDataFetch();

  return (
    <div>
      <FilterBar filter={filter} onFilter={setFilter} />
      <SortBar sortBy={sortBy} onSort={setSortBy} />
      {data.map(item => (
        <DashboardCard key={item.id} item={item} />
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode
// After: Memoized card + stable callbacks
const DashboardCard = React.memo(function DashboardCard({ item, onView }) {
  return (
    <div className="card">
      <h3>{item.title}</h3>
      <button onClick={() => onView(item.id)}>View</button>
    </div>
  );
});

function Dashboard() {
  const [filter, setFilter] = useState("all");
  const [sortBy, setSortBy] = useState("name");
  const data = useDataFetch();

  const handleView = useCallback((id) => navigate(`/item/${id}`), []);

  const filteredData = useMemo(
    () => data
      .filter(item => filter === "all" || item.status === filter)
      .sort((a, b) => a[sortBy].localeCompare(b[sortBy])),
    [data, filter, sortBy]
  );

  return (
    <div>
      <FilterBar filter={filter} onFilter={setFilter} />
      <SortBar sortBy={sortBy} onSort={setSortBy} />
      {filteredData.map(item => (
        <DashboardCard key={item.id} item={item} onView={handleView} />
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Part 2 Summary

Tool What it does Use when
React.memo Skips re-render if props unchanged Heavy leaf components
useMemo Caches computed value Expensive calculations
useCallback Caches function reference Passing callbacks to memo'd children
useRef Stable mutable value Timers, previous values, DOM refs
State colocation Keeps state local State only used by part of the tree
Children as slots Avoids ownership re-renders Wrappers/layout components
Split Context Reduces context consumers Large apps with mixed context data

Next: Part 3 — Forms Optimization


PART 3 — Forms Optimization: Controlled vs Uncontrolled, React Hook Form, Zod & Enterprise Patterns

Forms are the #1 source of subtle re-render bugs in real apps. This part explains why and fixes it.


Why Forms Become Slow

Every keystroke in a controlled input fires onChange → sets state → triggers a re-render. In a simple form this is fine. In a form with 30+ fields, conditional visibility, real-time validation, and nested objects, you can hit 30–60 re-renders per second while the user types.

// Classic controlled form — every keystroke re-renders the entire form
function SlowForm() {
  const [formData, setFormData] = useState({
    firstName: "",
    lastName: "",
    email: "",
    phone: "",
    // ... 25 more fields
  });

  const handleChange = (e) => {
    setFormData(prev => ({ ...prev, [e.target.name]: e.target.value }));
  };

  return (
    <form>
      <input name="firstName" value={formData.firstName} onChange={handleChange} />
      <input name="lastName"  value={formData.lastName}  onChange={handleChange} />
      {/* 28 more inputs — all re-render on every keystroke */}
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Controlled vs Uncontrolled Inputs

Controlled Uncontrolled
Value stored in React state DOM
Access value via value prop ref.current.value
Re-renders on change Yes (every keystroke) No
Validation timing Real-time On submit or blur
React DevTools Visible in state Not visible
Best for Real-time feedback, dynamic forms Performance-critical forms
// Controlled — React owns the value
function ControlledInput() {
  const [value, setValue] = useState("");
  return <input value={value} onChange={e => setValue(e.target.value)} />;
}

// Uncontrolled — DOM owns the value
function UncontrolledInput() {
  const ref = useRef(null);
  const handleSubmit = () => console.log(ref.current.value);
  return <input ref={ref} defaultValue="" />;
}
Enter fullscreen mode Exit fullscreen mode

When to use each:

  • Controlled: real-time validation, dependent fields (e.g. if country = US, show State dropdown), dynamic UI based on form values
  • Uncontrolled: simple forms, file inputs (<input type="file"> is always uncontrolled), performance-critical enterprise forms

React Hook Form — The Right Architecture

React Hook Form (RHF) uses an uncontrolled-first approach with refs. Your inputs read from and write to the DOM directly. React is only notified on submit or on validation triggers — not on every keystroke.

npm install react-hook-form
Enter fullscreen mode Exit fullscreen mode
import { useForm } from "react-hook-form";

function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting }
  } = useForm({
    defaultValues: { email: "", password: "" }
  });

  const onSubmit = async (data) => {
    await loginUser(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        {...register("email", {
          required: "Email is required",
          pattern: { value: /\S+@\S+\.\S+/, message: "Invalid email" }
        })}
        placeholder="Email"
      />
      {errors.email && <p>{errors.email.message}</p>}

      <input
        type="password"
        {...register("password", {
          required: "Password is required",
          minLength: { value: 8, message: "Min 8 characters" }
        })}
        placeholder="Password"
      />
      {errors.password && <p>{errors.password.message}</p>}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Logging in..." : "Login"}
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Result: Zero re-renders while typing. Only re-renders when errors appear or on submit.


How RHF Works Internally

graph LR
    A[User types] --> B[DOM Input]
    B --> C[RHF ref tracks value]
    C --> D{Validation trigger?}
    D -- onChange mode --> E[Validate + update errors state]
    D -- onBlur mode --> F[Validate on blur only]
    D -- onSubmit mode --> G[Validate on submit only]
    E --> H[Re-render only error section]
    F --> H
    G --> H
Enter fullscreen mode Exit fullscreen mode

register() returns { name, ref, onChange, onBlur }. The ref gives RHF direct DOM access. The onChange in RHF's version doesn't call setState — it writes to an internal store.

Only formState changes (errors, isSubmitting, isDirty, etc.) cause re-renders, and RHF uses a subscription model so only the component that subscribes to a specific field's state re-renders.


Zod + RHF — Production-Grade Validation

npm install zod @hookform/resolvers
Enter fullscreen mode Exit fullscreen mode
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

// Define schema once — reuse for client AND server validation
const registerSchema = z.object({
  username: z.string().min(3, "Username must be at least 3 characters"),
  email: z.string().email("Invalid email address"),
  password: z.string()
    .min(8, "Password must be at least 8 characters")
    .regex(/[A-Z]/, "Must contain at least one uppercase letter")
    .regex(/[0-9]/, "Must contain at least one number"),
  confirmPassword: z.string()
}).refine(data => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ["confirmPassword"]
});

type RegisterFormData = z.infer<typeof registerSchema>;

function RegisterForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<RegisterFormData>({
    resolver: zodResolver(registerSchema)
  });

  return (
    <form onSubmit={handleSubmit(data => console.log(data))}>
      <input {...register("username")} placeholder="Username" />
      {errors.username && <p>{errors.username.message}</p>}

      <input {...register("email")} placeholder="Email" />
      {errors.email && <p>{errors.email.message}</p>}

      <input type="password" {...register("password")} placeholder="Password" />
      {errors.password && <p>{errors.password.message}</p>}

      <input type="password" {...register("confirmPassword")} placeholder="Confirm" />
      {errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}

      <button type="submit">Register</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

RHF vs Formik (2026)

React Hook Form Formik
Re-renders per keystroke 0 1+
Bundle size ~9kb ~15kb
TypeScript support Excellent (native) Good
Validation Built-in + Zod/Yup Yup (mainly)
React 19 Actions support Yes (v7.50+) Partial
Learning curve Low Medium
Community in 2026 Dominant Declining

Verdict in 2026: RHF is the industry standard. Formik has not kept pace with React 19 features like Actions and Server Components.


Multi-Step Forms

import { useForm, FormProvider } from "react-hook-form";

const STEPS = ["Personal Info", "Address", "Review"];

function MultiStepForm() {
  const [currentStep, setCurrentStep] = useState(0);
  const methods = useForm({
    defaultValues: {
      firstName: "", lastName: "",
      street: "", city: "", zip: "",
    },
    mode: "onBlur"
  });

  const next = async () => {
    const fieldsForStep = currentStep === 0
      ? ["firstName", "lastName"]
      : ["street", "city", "zip"];

    const valid = await methods.trigger(fieldsForStep);
    if (valid) setCurrentStep(s => s + 1);
  };

  return (
    <FormProvider {...methods}>
      <StepIndicator steps={STEPS} current={currentStep} />

      {currentStep === 0 && <PersonalStep />}
      {currentStep === 1 && <AddressStep />}
      {currentStep === 2 && <ReviewStep />}

      <div className="nav">
        {currentStep > 0 && (
          <button onClick={() => setCurrentStep(s => s - 1)}>Back</button>
        )}
        {currentStep < 2
          ? <button onClick={next}>Next</button>
          : <button onClick={methods.handleSubmit(onFinalSubmit)}>Submit</button>
        }
      </div>
    </FormProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Enterprise Forms — Dynamic Field Arrays

import { useForm, useFieldArray } from "react-hook-form";

function TeamForm() {
  const { control, register, handleSubmit } = useForm({
    defaultValues: { members: [{ name: "", role: "" }] }
  });

  const { fields, append, remove } = useFieldArray({
    control,
    name: "members"
  });

  return (
    <form onSubmit={handleSubmit(console.log)}>
      {fields.map((field, index) => (
        <div key={field.id}> {/* field.id is a stable key — not the index */}
          <input {...register(`members.${index}.name`)} placeholder="Name" />
          <input {...register(`members.${index}.role`)} placeholder="Role" />
          <button type="button" onClick={() => remove(index)}>Remove</button>
        </div>
      ))}

      <button type="button" onClick={() => append({ name: "", role: "" })}>
        Add Member
      </button>
      <button type="submit">Save Team</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Part 3 Summary

  • Controlled inputs re-render on every keystroke — this scales badly
  • React Hook Form is uncontrolled-first — near-zero re-renders while typing
  • Zod gives you type-safe validation shared across client and server
  • useFieldArray handles dynamic lists without index-as-key bugs
  • In 2026, RHF + Zod is the industry-standard form stack

Next: Part 4 — React Compiler, Profiling & Production Checklist


PART 4 — React Compiler 1.0, Profiling, Production Checklist & Common Mistakes

The biggest shift in React performance since Hooks. Here's what it means for everything you just learned.


The React Compiler Changes Everything

The React Compiler reached stable status in late 2025. Instead of manually annotating every function and value that needs memoization, the compiler analyzes your code at build time and automatically inserts optimizations where they're actually needed.

Meta estimated that 60–70% of performance issues in their codebases were related to missing or incorrect memoization. If this was a problem even for the engineers who invented React, it was an unsustainable burden for the broader community.

The compiler analyzes your component tree at build time and inserts the equivalent of useMemo, useCallback, and React.memo automatically.


How the Compiler Works

React Compiler is a build-time Babel transform. It analyzes your component source code, understands the data flow between props, state, and derived values, and inserts the optimal memoization calls. The output is standard JavaScript — there is no runtime library, no special runtime behavior, and no changes to how React evaluates components at runtime.

The key insight: React's programming model (pure functions, immutable props, declarative rendering) provides enough information to infer memoization automatically. If the compiler can prove that a value's dependencies haven't changed, it can safely skip recomputing that value.

The Transformation in Action

// What you write — clean, simple code
function ProductList({ products, onSelect }) {
  const sortedProducts = [...products].sort((a, b) =>
    a.name.localeCompare(b.name)
  );

  const handleSelect = (product) => {
    onSelect(product.id);
  };

  const expensiveFiltered = sortedProducts.filter(p => p.price > 100);

  return (
    <ul>
      {expensiveFiltered.map(product => (
        <ProductItem
          key={product.id}
          product={product}
          onSelect={handleSelect}
        />
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode
// What the compiler generates (conceptually simplified)
function ProductList({ products, onSelect }) {
  const $ = _cache(5);

  let sortedProducts = $[0] !== products
    ? ($[0] = products, $[1] = [...products].sort(...))
    : $[1];

  let handleSelect = $[2] !== onSelect
    ? ($[2] = onSelect, $[3] = (product) => onSelect(product.id))
    : $[3];

  let expensiveFiltered = $[4] !== sortedProducts
    ? ($[4] = sortedProducts, $[5] = sortedProducts.filter(p => p.price > 100))
    : $[5];

  // JSX is also cached
}
Enter fullscreen mode Exit fullscreen mode

You write clean code. The compiler handles the caching.


Enabling the Compiler

Next.js 16:

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  reactCompiler: true,
};

export default nextConfig;
Enter fullscreen mode Exit fullscreen mode

Vite:

npm install babel-plugin-react-compiler
Enter fullscreen mode Exit fullscreen mode
// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: [["babel-plugin-react-compiler"]]
      }
    })
  ]
});
Enter fullscreen mode Exit fullscreen mode

Incremental adoption for existing codebases:

["babel-plugin-react-compiler", {
  sources: (filename) => filename.includes("/src/components/"),
}]
Enter fullscreen mode Exit fullscreen mode

Real-World Performance Numbers

  • Sanity Studio: 1,231 of 1,411 components compiled — 20–30% overall reduction in render time and latency
  • Wakelet: LCP improved 10% (2.6s to 2.4s), INP improved 15% (275ms to 240ms)
  • Typical re-render reduction across projects: ~40%

Does useMemo/useCallback Still Have a Place?

Yes — in specific scenarios.

useMemo and useCallback remain useful as escape hatches for precise control, particularly when a memoized value is used as an effect dependency. For new code, rely on the compiler by default.

One important limitation: React Compiler's memoization is per-component, not shared globally. If an expensive calculation is used in many different components, each will compute it independently. Profiling first is recommended before adding manual memoization.

Scenario Why the compiler can't handle it alone
Shared expensive calculation across components Compiler memoizes per-component, not globally
Effect dependencies where object identity matters You need explicit control over the reference
Third-party library integrations Some APIs require stable references the compiler can't guarantee
Mutating data patterns Compiler assumes immutability — mutations break its analysis

Profiling — Find Problems Before You Fix Them

React DevTools Profiler

  1. Open Chrome DevTools → Profiler tab
  2. Click Record
  3. Interact with your app
  4. Stop recording
  5. Inspect the flame graph
Red bars    = expensive renders (>16ms = dropped frame)
Yellow      = medium cost
Green       = fast renders

Hover a bar to see:
- Component name
- Render duration
- Why it rendered (prop/state/context/parent)
- How many times it rendered
Enter fullscreen mode Exit fullscreen mode

Why Did You Render

npm install @welldone-software/why-did-you-render
Enter fullscreen mode Exit fullscreen mode
// src/wdyr.js — import this as the FIRST line in index.js
import React from "react";

if (process.env.NODE_ENV === "development") {
  const whyDidYouRender = require("@welldone-software/why-did-you-render");
  whyDidYouRender(React, {
    trackAllPureComponents: true,
  });
}
Enter fullscreen mode Exit fullscreen mode
// Flag specific components for tracking
function ExpensiveDashboard() {
  return <div>...</div>;
}

ExpensiveDashboard.whyDidYouRender = true;
// Console now tells you exactly why each render happened
Enter fullscreen mode Exit fullscreen mode

Chrome Performance Tab

  1. Chrome DevTools → Performance tab
  2. Record → interact → Stop
  3. Look for Long Tasks (red bars above 50ms)
  4. Long Tasks during user interactions = bad INP score

React 19 Features That Reduce Unnecessary Work

useActionState — Built-in Async Form Handling

import { useActionState } from "react";

async function submitForm(prevState, formData) {
  const name = formData.get("name");
  try {
    await updateUsername(name);
    return { success: true, message: "Username updated!" };
  } catch (e) {
    return { success: false, message: e.message };
  }
}

function UsernameForm() {
  const [state, formAction, isPending] = useActionState(submitForm, null);

  return (
    <form action={formAction}>
      <input name="name" placeholder="New username" />
      <button type="submit" disabled={isPending}>
        {isPending ? "Saving..." : "Save"}
      </button>
      {state?.message && <p>{state.message}</p>}
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

useOptimistic — Instant UI Feedback

function LikeButton({ post }) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    post.likes,
    (state, increment) => state + increment
  );

  const handleLike = async () => {
    addOptimisticLike(1);          // instant UI update
    await likePost(post.id);       // actual API call
    // on failure, React auto-reverts to original state
  };

  return (
    <button onClick={handleLike}>
      {optimisticLikes} likes
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Enhanced Automatic Batching

All state updates in the same event loop batch into a single re-render — even inside async functions and timeouts.

async function handleFetch() {
  const data = await fetchData();
  setLoading(false);   // |
  setData(data);       // | -- single re-render
  setError(null);      // |
}
Enter fullscreen mode Exit fullscreen mode

Production Performance Checklist

Build
- [ ] React Compiler enabled
- [ ] Code splitting on routes (React.lazy)
- [ ] Dynamic imports for heavy libraries
- [ ] Bundle analyzer run (source-map-explorer)

Architecture
- [ ] Server Components for non-interactive content
- [ ] State colocated to lowest necessary component
- [ ] Context split by concern
- [ ] Stable keys on all lists (no index as key)
- [ ] React.memo only where profiler confirms need

Forms
- [ ] React Hook Form for medium/large forms
- [ ] Validation mode set to onBlur or onSubmit for non-critical fields
- [ ] Zod schema for complex validation

Lists
- [ ] Virtualization for lists over 100 items (@tanstack/react-virtual)

Monitoring
- [ ] React DevTools Profiler in development
- [ ] Core Web Vitals (LCP, INP, CLS) tracked in production
Enter fullscreen mode Exit fullscreen mode

Common Mistakes and Fixes

Mistake 1: Object/Array literals as props

// New array reference on every render — breaks memo
function Parent() {
  return <Child items={[]} />;
}

// Define outside the component
const EMPTY_ITEMS = [];
function Parent() {
  return <Child items={EMPTY_ITEMS} />;
}
Enter fullscreen mode Exit fullscreen mode

Mistake 2: Spreading state when only one field changes

// Spreading creates a new object — all consumers re-render
const [user, setUser] = useState({ name: "", age: 0 });
setUser(prev => ({ ...prev, name: "Alice" }));

// Split into atomic state or use useReducer for complex objects
const [name, setName] = useState("");
const [age, setAge] = useState(0);
Enter fullscreen mode Exit fullscreen mode

Mistake 3: Wrong useEffect dependencies

// Runs on every render — no dependency array
useEffect(() => { fetchData(userId); });

// Stale closure — uses initial value of userId forever
useEffect(() => { fetchData(userId); }, []);

// Correct
useEffect(() => { fetchData(userId); }, [userId]);
Enter fullscreen mode Exit fullscreen mode

Mistake 4: Defining components inside render

// New component type on every render — full remount every time
function Parent() {
  const Row = ({ item }) => <li>{item.name}</li>; // defined inside Parent
  return <ul>{items.map(i => <Row key={i.id} item={i} />)}</ul>;
}

// Define components at module level
const Row = ({ item }) => <li>{item.name}</li>;
function Parent() {
  return <ul>{items.map(i => <Row key={i.id} item={i} />)}</ul>;
}
Enter fullscreen mode Exit fullscreen mode

Mistake 5: Over-memoizing

// Memoization overhead outweighs any benefit here
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);

// Just do this
const fullName = `${firstName} ${lastName}`;
Enter fullscreen mode Exit fullscreen mode

Common Interview Questions (2026)

Q: What causes a React component to re-render?

State change, props change, context change, or the parent component re-rendering.

Q: What's the difference between useMemo and useCallback?

useMemo caches a value. useCallback caches a function reference. Both prevent re-creation on every render.

Q: When would you still use useMemo with the React Compiler?

When you need a shared expensive computation across components, or when object identity of a value matters specifically as a useEffect dependency.

Q: What is reconciliation?

The process React uses to diff the old and new Virtual DOM trees to determine the minimum set of real DOM changes needed.

Q: Why should you not use array index as a key?

Index keys break when the list is reordered or items are inserted/removed — React associates the wrong DOM nodes with the wrong data, causing visual bugs and lost state.

Q: What does the React Compiler do?

It's a build-time tool that automatically memoizes components and values, working on both React and React Native without requiring code rewrites.

Q: How do you prevent a Context provider from causing unnecessary re-renders?

Split context by concern, and memoize the context value with useMemo so the object reference is stable unless the data actually changes.


The 2026 React Performance Landscape

graph TD
    A[React Performance in 2026] --> B[Architecture Level]
    A --> C[Compiler Level]
    A --> D[Runtime Level]

    B --> B1[Server Components — shift work to server]
    B --> B2[Code Splitting — lazy load routes/features]
    B --> B3[State Colocation — keep state close to usage]

    C --> C1[React Compiler 1.0 — automatic memoization]
    C --> C2[~40% fewer re-renders, zero code change]
    C --> C3[useMemo is now an escape hatch, not the default]

    D --> D1[useOptimistic — instant UI feedback]
    D --> D2[Automatic Batching — fewer render cycles]
    D --> D3[Virtualization — for 100+ item lists]
Enter fullscreen mode Exit fullscreen mode

Final Summary

Layer Tool / Technique When to use
Architecture Server Components Non-interactive content
Architecture State Colocation State used in subtree only
Architecture Children/Slots Wrapper layout components
Compiler React Compiler 1.0 Always — enable it
Runtime React.memo Heavy components (profiler confirms)
Runtime useMemo Expensive calcs, effect deps
Runtime useCallback Stable refs for memo'd children
Forms React Hook Form All medium/large forms
Forms Zod Type-safe validation
Lists @tanstack/react-virtual 100+ item lists
Monitoring React DevTools Profiler Development
Monitoring Core Web Vitals Production

References


Updated July 2026 — React 19.2 and React Compiler 1.0

Tags: #react #javascript #webdev #performance

Top comments (0)