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)
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>
Gets compiled by Babel/esbuild into this (simplified):
{
type: "div",
props: { className: "card" },
children: [
{ type: "h1", props: {}, children: ["Hello"] },
{ type: "p", props: {}, children: ["World"] }
]
}
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>
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" />
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} />)}
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)
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>;
}
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]
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>;
});
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} />
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]
);
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
useEffectwhen 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}!`;
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} />;
}
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);
}, []);
}
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>;
}
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} />
</>
);
}
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>
);
}
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>
);
}
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>;
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} />)}
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>
);
}
// 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>
);
}
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>
);
}
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="" />;
}
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
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>
);
}
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
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
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>
);
}
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>
);
}
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>
);
}
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
-
useFieldArrayhandles 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>
);
}
// 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
}
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;
Vite:
npm install babel-plugin-react-compiler
// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [
react({
babel: {
plugins: [["babel-plugin-react-compiler"]]
}
})
]
});
Incremental adoption for existing codebases:
["babel-plugin-react-compiler", {
sources: (filename) => filename.includes("/src/components/"),
}]
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
- Open Chrome DevTools → Profiler tab
- Click Record
- Interact with your app
- Stop recording
- 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
Why Did You Render
npm install @welldone-software/why-did-you-render
// 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,
});
}
// Flag specific components for tracking
function ExpensiveDashboard() {
return <div>...</div>;
}
ExpensiveDashboard.whyDidYouRender = true;
// Console now tells you exactly why each render happened
Chrome Performance Tab
- Chrome DevTools → Performance tab
- Record → interact → Stop
- Look for Long Tasks (red bars above 50ms)
- 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>
);
}
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>
);
}
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); // |
}
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
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} />;
}
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);
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]);
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>;
}
Mistake 5: Over-memoizing
// Memoization overhead outweighs any benefit here
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);
// Just do this
const fullName = `${firstName} ${lastName}`;
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?
useMemocaches a value.useCallbackcaches 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
useEffectdependency.
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
useMemoso 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]
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
- react.dev/learn/react-compiler — Official compiler docs
- react.dev/blog — React 19.2 release notes
- react-hook-form.com — RHF documentation
- zod.dev — Zod schema validation
- tanstack.com/virtual — List virtualization
Updated July 2026 — React 19.2 and React Compiler 1.0
Tags: #react #javascript #webdev #performance
Top comments (0)