Module 7: Real-World Usage
Rendering Performance: What Re-renders and When to Memoize
What actually triggers a re-render, how to measure before optimizing, and how memo, useMemo, useCallback and the React Compiler fit together.
Performance advice in React tends to come as a list of hooks to sprinkle everywhere. That's backwards. Most apps are fast enough without any of it, and wrapping things "just in case" adds complexity without helping. The skill is knowing what causes work, measuring where it hurts, and applying the smallest fix.
What triggers a re-render#
A component renders again when:
- Its own state changes.
- Its parent renders, which is the one that surprises people. By default, when a parent re-renders, all its children re-render too, whether or not their props changed.
- A context it reads changes.
Props changing is not on that list. That's a common misunderstanding. Props have no effect on whether a child renders, because the child renders when the parent does. Props only matter if you opt in to skipping with memo.
App state changes
│
▼
App renders
├─► Header renders (even though it takes no props)
├─► Sidebar renders
└─► Content renders
├─► Chart renders
└─► Table renders
And again, a re-render only means React called your function. It then compares the output to the previous one and only touches the DOM where it differs. A render that produces the same output is cheap. It only becomes a problem if the component does heavy work during render, or the tree is huge.
Measure first#
Open React DevTools, switch to the Profiler tab, press record, interact with your app, and stop. You get a flame graph of which components rendered and how long each took. Use the "highlight updates when components render" option in the settings to see re-renders flash on screen.
If nothing feels slow and the profiler shows renders under a few milliseconds, stop. That's the right outcome.
Fix 1: move state down#
Often the cleanest fix needs no hooks at all. If a piece of state is only used in one small part, don't keep it in a big parent.
// Typing in the input re-renders the slow list on every keystroke
function Page() {
const [text, setText] = useState("");
return (
<>
<input value={text} onChange={(e) => setText(e.target.value)} />
<ExpensiveList />
</>
);
}
// Move the state into its own component
function SearchInput() {
const [text, setText] = useState("");
return <input value={text} onChange={(e) => setText(e.target.value)} />;
}
function Page() {
return (
<>
<SearchInput />
<ExpensiveList /> {/* no longer re-renders on typing */}
</>
);
}
Fix 2: pass children through#
If the state has to live in the parent, pass the slow part in as children. The element was created by an outer component that isn't re-rendering, so React sees the same element object and skips it.
function Layout({ children }) {
const [theme, setTheme] = useState("light");
return <div className={theme}>{children}</div>;
}
<Layout>
<ExpensiveList /> {/* created by the parent, unaffected by Layout's state */}
</Layout>
Fix 3: React.memo#
memo wraps a component so React skips rendering it when its props are shallowly equal to last time.
import { memo } from "react";
const ProductRow = memo(function ProductRow({ product, onSelect }) {
console.log("render", product.id);
return <li onClick={() => onSelect(product.id)}>{product.name}</li>;
});
Shallow comparison means each prop is compared with Object.is. Primitives compare by value, objects, arrays and functions compare by reference. And this is where memo often fails to help:
function List({ products }) {
const [selected, setSelected] = useState(null);
return products.map((p) => (
<ProductRow
key={p.id}
product={p}
onSelect={(id) => setSelected(id)} // a NEW function every render, so memo never skips
/>
));
}
onSelect gets a fresh function identity on every render of List, so the shallow comparison always fails. That's the job of useCallback.
useCallback and useMemo#
Both cache something between renders and only recompute when dependencies change.
// useCallback caches a FUNCTION
const handleSelect = useCallback((id) => setSelected(id), []);
// useMemo caches a computed VALUE
const sorted = useMemo(
() => [...products].sort((a, b) => a.price - b.price),
[products]
);
useCallback(fn, deps) is the same as useMemo(() => fn, deps). Now the ProductRow example works: handleSelect keeps one identity, product is the same object, so memo skips every row except the ones that changed.
Rules for when they're worth it:
useCallbackis useful when you pass the function to amemo-wrapped child, or when it's a dependency of an effect.useMemois useful for a genuinely expensive calculation (sorting thousands of items, heavy parsing), or to keep an object's identity stable for a memoized child or effect dependency.- Neither is useful for a cheap calculation or for a function passed to a plain element like a
button. You pay for the memoization bookkeeping and gain nothing.
A memoization that isn't paired with a memo consumer or a dependency array that cares about identity is usually dead weight.
Keys and long lists#
Stable keys (from the lists lesson) matter for performance too, since bad keys cause remounting rather than updating. For lists with thousands of rows, rendering them all is the real cost. Virtualization libraries such as TanStack Virtual or react-window render only the rows in view.
Keeping typing responsive#
React has two tools for work that's slow but not urgent:
import { useDeferredValue, useTransition } from "react";
// 1. Defer a value: the input stays snappy, the heavy list catches up
const deferredQuery = useDeferredValue(query);
const results = useMemo(() => filterHugeList(deferredQuery), [deferredQuery]);
// 2. Mark a state update as non-urgent
const [isPending, startTransition] = useTransition();
startTransition(() => setTab("reports"));
They tell React "handle this input first, and do the expensive rendering when you can, interrupting it if the user types again." They're worth learning when you have a real responsiveness problem.
The React Compiler#
Here's the big change in recent years. The React Compiler (stable since late 2025) is a build-time tool that analyzes your components and automatically applies the equivalent of memo, useMemo and useCallback where it's safe. You write plain code, and it adds the memoization.
What this means in practice:
- In a project with the compiler enabled, you write far fewer manual
useMemoanduseCallbackcalls. Often none. - It relies on your code following the rules (pure renders, no mutation). The ESLint rules help you find violations.
- You still need to understand all of the above, because you'll work in codebases without the compiler, you'll still use the techniques of moving state and passing children, and the compiler isn't a substitute for sensible structure.
It installs through a Babel plugin that the Vite React plugin can run. The exact config depends on your versions, so follow the installation page in the official React docs when you set it up.
An honest workflow#
- Build it simply.
- If something feels slow, record a profile.
- Find the component that's slow or re-rendering too often.
- Try moving state down or passing children.
- If that's not possible, add
memo(anduseCallback/useMemofor the props that break it). - Profile again to confirm it helped.
Where people get burned#
- Wrapping everything in
useMemoanduseCallbackby default. - Using
memoand then passing inline objects, arrays or arrow functions as props, which defeats it. - Forgetting dependencies in
useMemoanduseCallbackand returning stale values. - Optimizing without profiling.
- Believing "re-render" equals "DOM update".
- Rendering ten thousand rows without virtualization.
Practice#
- Build a page with a text input and a deliberately slow list (add a loop that burns 5ms per item in render). Feel the lag, then fix it by moving state down.
- Fix the same example a second way, using
children. - Create a
memorow component and a parent with a selection state. Log renders. Observe that memo doesn't help until you adduseCallback. - Use the Profiler to record a click and identify which components rendered unnecessarily.