Knowing exactly when components re-render and when state survives explains most performance issues and 'my state reset' bugs.
Every screen update goes through three steps:
- Trigger: the initial
root.render(), or a state update in some component. - Render: React calls your components to compute the new JSX. When a component re-renders, by default all its descendants re-render too, whether or not their props changed.
- Commit: React applies the minimal set of DOM changes. If the output is the same, the DOM isn't touched; rendering is not the same as repainting.
Producing the new tree and comparing it with the previous one is reconciliation. React doesn't do a general tree diff (that would be O(n³)); it uses two heuristics:
- Elements of different types at the same position produce different subtrees. Switching
<div>to<section>, or<Counter>to<Timer>, unmounts the old subtree (losing its state and DOM) and mounts a new one. - Among children, keys identify which element is which across renders.
This defines when state is preserved: state belongs to a position in the tree, not to the component function. Same component type at the same position keeps its state, even if you pass different props. {isAdmin ? <Profile user={a} /> : <Profile user={b} />} keeps one Profile's state for both users.
To reset state deliberately, change the key: <Profile key={user.id} user={user} /> makes each user a different component instance. That's the idiomatic way to reset a form when the selected item changes, instead of an effect.
Corollaries: never define components inside other components (new type every render), and don't flip a subtree's wrapper element type unless you want a remount. Because re-rendering cascades, pushing state down to the component that needs it, or passing static JSX as children, keeps renders small even before any memoization.
Example
import { useState } from "react";
function Counter({ label }) {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{label}: {count}</button>;
}
export default function Scoreboard() {
const [isPlayerA, setIsPlayerA] = useState(true);
return (
<div>
{/* Same type, same position: the count is SHARED across players */}
{isPlayerA ? <Counter label="Taylor" /> : <Counter label="Sarah" />}
{/* Different keys: each player gets their own fresh state */}
{isPlayerA ? <Counter key="taylor" label="Taylor" /> : <Counter key="sarah" label="Sarah" />}
<button onClick={() => setIsPlayerA(p => !p)}>Next player</button>
</div>
);
}Remember
- Updates go trigger, render, commit; re-rendering a parent re-renders its children by default
- Same type at the same position preserves state; a different type or key remounts
- Change a component's
keyto reset its state on purpose
Watch out for
- Wrapping content in a different element conditionally (
<div>vs<span>) and unexpectedly losing child state.