Concept
The beginner framing: when state or props change, React re-runs the affected component functions to figure out what the UI should look like now, then updates the real DOM to match.
The precise mental model: React splits this into two genuinely separate phases with two genuinely separate jobs:
- Render phase, React calls your component functions to find out what they want to render this time. This phase must be a pure calculation: given the same props and state, it must return the same result, with zero observable side effects (no DOM mutation, no network calls, no mutating variables outside the function). React is explicitly allowed to call this phase more than once, throw away the result, pause it, or redo it, which is only safe because it's supposed to be pure.
- Commit phase, React takes the results of the render phase and actually applies them: creating/updating/deleting real DOM nodes, and running effects (
useEffect) that were scheduled. This is the only phase where touching the real world (DOM, network, subscriptions) is meant to happen.
By default, a state update re-renders the entire subtree below it, every child component gets called again during the render phase, whether or not its own props actually changed (see the live demo in State). This isn't a bug; it's React's simplest possible default: recompute everything below the change, then compare, rather than trying to guess in advance what's affected.
React.memo: opting a component out of the default cascade
const ExpensiveRow = React.memo(function ExpensiveRow({ label }) {
return <div>{label}</div>;
});React.memo wraps a component so that, before re-rendering it, React first does a shallow comparison of its new props against its previous props. If every prop is reference-equal (Object.is) to last time, React skips calling the component's function entirely for this render, reusing the previous result, genuinely bailing out of the cascade, not just skipping the DOM update.
Click the button a few times. "Plain" re-renders every time (no memo, always cascades). "memo(), no props" stays frozen at 1 (memoized, and its props never change). "memo() + value prop" still re-renders every time too, memo compares props, and `value` genuinely changes each click, so memoization can't (and shouldn't) skip it.
Why React (in development) calls your component function twice
If you've watched the render counts in the demos above climb by 2 per click instead of 1, that's React Strict Mode, enabled by default in development in this app (and in Next.js apps generally), it deliberately calls your component function (and a few other lifecycle-ish functions) twice in a row, throwing away one result, specifically to help you notice if your render logic accidentally isn't pure. If calling a function twice produces different visible behavior (a counter that increments on its own, a side effect firing an extra time), that's Strict Mode surfacing a real bug in your code, production builds never do this double-call; it's a development-only diagnostic.
function BadCounter() {
renderCount++; // ❌ mutating a variable OUTSIDE the component, during render, impure
return <div>{renderCount}</div>;
}Under Strict Mode, this visibly "jumps by 2" instead of 1 on each render, not because Strict Mode is broken, but because it's exposing that this component's render isn't a pure calculation.
Try It
Predict what happens across renders, then check yourself.
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
<ExpensiveChild label="static" />
</div>
);
}
const ExpensiveChild = React.memo(function ExpensiveChild({ label }) {
console.log("ExpensiveChild rendered");
return <div>{label}</div>;
});Solution
"ExpensiveChild rendered" logs once, on mount, and never again, no matter how many times the button is clicked. Parent re-renders every click (its own state changed), but ExpensiveChild receives the exact same label="static" prop reference every time (a string literal is always === to itself), so React.memo's shallow comparison finds no difference and skips calling the component function entirely. This is the memo boundary in action: the cascade from Parent's state change stops here.
Implement It Yourself
Implement a simplified memo to see exactly what the shallow-comparison bailout mechanism is doing:
function shallowEqual(objA, objB) {
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) return false;
return keysA.every((key) => Object.is(objA[key], objB[key]));
}
function myMemo(Component) {
let lastProps = null;
let lastResult = null;
return function MemoizedComponent(props) {
if (lastProps !== null && shallowEqual(lastProps, props)) {
return lastResult; // bail out, reuse the PREVIOUS result, don't call Component again
}
lastProps = props;
lastResult = Component(props);
return lastResult;
};
}
const ExpensiveRow = myMemo(function ExpensiveRow({ label }) {
console.log("rendering", label);
return { type: "div", props: { children: label } };
});
ExpensiveRow({ label: "a" }); // logs "rendering a", first call, no cache yet
ExpensiveRow({ label: "a" }); // NO log, shallowEqual sees the same props, bails out
ExpensiveRow({ label: "b" }); // logs "rendering b", label actually changedThis is a real, if simplified, model of React.memo's actual mechanism: cache the last props and result, and only re-run the component when a shallow comparison says something genuinely changed.
Under the Hood
The render phase's purity requirement is exactly Functional Programming's purity principle, applied to components specifically: same input (props/state) → same output, no side effects. This isn't a React-specific rule invented in isolation, it's why React can safely call your function twice under Strict Mode, pause a render mid-way and resume it later (concurrent rendering), or skip calling it entirely under memo, every one of those capabilities depends on render being a pure calculation, exactly the same guarantee that makes a pure function safely memoizable in plain JavaScript.
Common Mistakes
1. Performing side effects during render instead of in an effect
function Logger({ message }) {
console.log(message); // borderline, logging is usually harmless, but...
localStorage.setItem("last", message); // ❌ a REAL side effect, happening during render
return <div>{message}</div>;
}Anything that reaches outside the render (writing to storage, mutating a module-level variable, starting a subscription) belongs in useEffect, which runs during the commit phase, specifically because render can be called more than once (Strict Mode, concurrent features) and isn't the place for anything that shouldn't happen twice or happen before the DOM is actually updated.
2. Assuming React.memo does a deep comparison
const Row = React.memo(function Row({ user }) { ... });
<Row user={{ name: "Ada" }} /> // a NEW object every render, memo's shallow check
// sees a different reference EVERY time, never bails outReact.memo's default comparison is shallow (Object.is per prop), not deep, an inline object/array/function literal passed as a prop defeats it completely, since it's a new reference every render regardless of whether the contents "look the same" (see Props's performance section).
3. Wrapping everything in memo by default
const TinyLabel = React.memo(function TinyLabel({ text }) { return <span>{text}</span>; });memo itself has a cost, an extra comparison on every potential re-render. For a component that's already cheap to render, wrapping it in memo can cost more than it saves. Reach for memo on components that are either expensive to render or re-render very frequently with genuinely-often-unchanged props, verified with the Profiler, not applied everywhere reflexively.
Best Practices
- Keep the render phase pure, no DOM mutation, no network calls, no writing to variables outside the function. If something needs to reach outside the component, it belongs in an effect.
- Reach for
React.memodeliberately, after profiling shows a specific component re-renders expensively and often with unchanged props, not as a default wrapper on every component. - Pair
memowith stable prop references (useMemo/useCallbackin the parent), memoizing a component whose props are recreated as new literals every render accomplishes nothing. - Treat a Strict-Mode double-render bug as a real bug, not an annoyance to silence, it's telling you the render phase isn't pure, which will cause real, harder-to-diagnose problems under concurrent rendering in production.
Performance Tips
- React DevTools' Profiler is the authoritative tool for this entire topic, it records each commit, shows exactly which components rendered, how long each took, and (with "why did this render" enabled) why, prop changed, state changed, parent re-rendered, or context changed. Use it before deciding where
memois actually worth adding. - The render phase re-running doesn't necessarily mean expensive DOM work, React still diffs the new render output against the previous one (see Reconciliation) and only touches the real DOM for what actually changed. Cascading re-renders are wasted render-phase computation, but not necessarily wasted DOM work, profile before assuming a cascade is the actual bottleneck.
- Because Strict Mode double-invokes render (and a few effect lifecycles) only in development, any performance measurement taken in dev with Strict Mode on will look roughly 2x worse than production for the render phase specifically, always validate real performance numbers against a production build.
