What Triggers a Re-render
The Most Common React Misconception
Ask a React developer "what causes a re-render?" and most will say "when props change." This is wrong, and this misunderstanding is the root cause of most React performance problems.
function Parent() {
const [count, setCount] = useState(0);
console.log('Parent renders');
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
<Child name="Alice" /> {/* "Alice" never changes */}
</div>
);
}
function Child({ name }) {
console.log('Child renders'); // This logs on EVERY button click
return <p>Hello, {name}</p>;
}
Click the button. Child re-renders even though name is always "Alice". Props didn't change, but Child rendered anyway. Why? Because parent re-rendered.
The Mental Model
Think of re-renders as rolling downhill. When a component re-renders, every component below it in the tree also re-renders — like a boulder rolling down a mountain, hitting everything in its path. The boulder starts when one of three things happens:
- setState — someone pushed the boulder (explicit state change)
- Parent re-rendered — a boulder from above hit this component
- Context change — an earthquake shook the whole mountain (context value changed)
Props are NOT boulders. They're just the shape of the path. Changing props alone doesn't push anything — the parent must re-render first, and that's what triggers the child.
Trigger 1: setState (or useReducer dispatch)
This is the only way a component triggers its own re-render:
function Counter() {
const [count, setCount] = useState(0);
// Calling setCount triggers a re-render of Counter
// AND all of its children
return (
<div>
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
<ExpensiveChild /> {/* Also re-renders — it's a child */}
</div>
);
}
React has a bailout optimization: if setState receives the same value (via Object.is), React skips the re-render. But this only works for primitive values and stable references. setItems([...items]) always triggers a re-render because it creates a new array reference.
Trigger 2: Parent Re-renders
When a parent component re-renders, all its children re-render. Not just children whose props changed — ALL children:
function App() {
const [theme, setTheme] = useState('light');
return (
<div>
<ThemeToggle onToggle={() => setTheme(t => t === 'light' ? 'dark' : 'light')} />
{/* ALL of these re-render when theme changes, even the ones
that don't use theme at all */}
<Header />
<Sidebar />
<MainContent />
<Footer />
</div>
);
}
When setTheme fires, App re-renders. React then re-renders Header, Sidebar, MainContent, and Footer — regardless of whether they use theme or not.
This is React's default behavior, not a bug. React can't know whether a child's output would change without actually calling the component function. Calling the function IS the render. React optimizes by comparing the output, not by predicting it.
Trigger 3: Context Change
When a context value changes, every consumer of that context re-renders — even if the specific value they use didn't change:
const AppContext = createContext({ theme: 'light', locale: 'en' });
function App() {
const [state, setState] = useState({ theme: 'light', locale: 'en' });
return (
<AppContext.Provider value={state}>
<ThemeDisplay /> {/* Re-renders when locale changes too */}
<LocaleDisplay /> {/* Re-renders when theme changes too */}
</AppContext.Provider>
);
}
function ThemeDisplay() {
const { theme } = useContext(AppContext);
// Re-renders whenever ANY part of AppContext changes
// Not just when 'theme' changes
return <div>Theme: {theme}</div>;
}
Even though ThemeDisplay only reads theme, it re-renders when locale changes because the entire context object reference changed.
What Does NOT Trigger a Re-render
Understanding what doesn't cause re-renders is equally important:
// Mutating state directly — NO RE-RENDER
const [user, setUser] = useState({ name: 'Alice' });
user.name = 'Bob'; // Mutated, but no re-render. React doesn't know.
// Changing a ref — NO RE-RENDER
const countRef = useRef(0);
countRef.current = 42; // Updated, but no re-render
// Changing a variable — NO RE-RENDER
let localVar = 'hello';
localVar = 'world'; // Not state, not tracked by React
// Props changing without parent re-rendering — IMPOSSIBLE
// Props can only change when the parent provides new values,
// which only happens when the parent re-renders.
// "Props changed" is always preceded by "parent re-rendered."
"But I passed new props and the component re-rendered!" Yes — because the PARENT re-rendered (which is what passed the new props). The causation chain is:
- Parent calls
setState→ parent re-renders → parent returns new JSX with new props → child re-renders
The child re-rendered because step 2 happened (parent re-rendered), not because of the new props in step 3. If you wrap the child in React.memo, it would NOT re-render despite receiving new JSX from the parent — because memo checks whether the new props actually differ from the old ones.
The Re-render Cascade Visualized
function App() { // Renders: always (root)
const [count, setCount] = useState(0);
return (
<Layout> {/* Renders: when App renders */}
<Header> {/* Renders: when Layout renders */}
<Logo /> {/* Renders: when Header renders */}
<Nav /> {/* Renders: when Header renders */}
</Header>
<Main> {/* Renders: when Layout renders */}
<Counter {/* Renders: when Main renders */}
count={count}
onIncrement={() => setCount(c => c + 1)}
/>
<Sidebar /> {/* Renders: when Main renders (!) */}
</Main>
</Layout>
);
}
Clicking increment:
setCount→ App renders- App renders → Layout renders → Header, Main render
- Header renders → Logo, Nav render
- Main renders → Counter, Sidebar renders
Sidebar has nothing to do with count. It receives no props related to count. But it re-renders because its parent (Main) re-rendered, because Main's parent (Layout) re-rendered, because Layout's parent (App) called setState.
This cascade is the #1 source of unnecessary renders in React applications.
Production Scenario: The 200-Component Cascade
A dashboard renders 200 chart widgets. The header has a notification bell with an unread count:
function Dashboard() {
const [unreadCount, setUnreadCount] = useState(0);
useEffect(() => {
const ws = new WebSocket('/notifications');
ws.onmessage = () => setUnreadCount(c => c + 1);
return () => ws.close();
}, []);
return (
<>
<Header unreadCount={unreadCount} />
{/* All 200 charts re-render on every notification */}
<ChartGrid charts={charts} />
</>
);
}
Every incoming notification increments unreadCount, which re-renders Dashboard, which re-renders all 200 charts. The fix is state colocation — move the notification state closer to where it's used:
function Dashboard() {
return (
<>
<Header /> {/* Manages its own notification state */}
<ChartGrid charts={charts} />
</>
);
}
function Header() {
const [unreadCount, setUnreadCount] = useState(0);
useEffect(() => {
const ws = new WebSocket('/notifications');
ws.onmessage = () => setUnreadCount(c => c + 1);
return () => ws.close();
}, []);
return <header><NotificationBell count={unreadCount} /></header>;
}
Now setUnreadCount only re-renders Header and its children. ChartGrid is unaffected.
Common Mistakes
| What developers do | What they should do |
|---|---|
| Thinking prop changes trigger re-renders Props can only change when the parent provides new values during its own render. The parent's render is the trigger | Parent re-rendering triggers child re-renders. New props are a consequence, not a cause |
| Placing state at the top of the tree 'for convenience' State at the top cascades re-renders through the entire tree. State lower in the tree only affects its subtree | Colocate state as close as possible to where it's used |
| Creating new objects/arrays in props without memoization style={`{color: 'red'}`} creates a new object every render. If the child is wrapped in React.memo, this defeats memoization because {} !== {} | Stabilize object references with useMemo when passed to memoized children |
| Assuming React only re-renders what changed Re-rendering (calling component functions) and DOM updating (commit phase) are separate. React re-renders broadly, then commits narrowly | React re-renders the entire subtree below the state change, then diffs for DOM changes |
Challenge
Challenge: Find the unnecessary re-renders
function App() {
const [searchQuery, setSearchQuery] = useState('');
const [selectedId, setSelectedId] = useState(null);
return (
<div>
<SearchBar
value=`{searchQuery}`
onChange=`{setSearchQuery}`
/>
<UserList
selectedId=`{selectedId}`
onSelect=`{setSelectedId}`
/>
<UserDetail userId={selectedId} />
<Footer />
</div>
);
}
// When the user types in SearchBar:
// 1. Which components re-render?
// 2. Which re-renders are unnecessary?
// 3. How would you fix it without React.memo?
Show Answer
1. All four children re-render: SearchBar, UserList, UserDetail, and Footer. Because App (parent) re-renders when setSearchQuery fires.
2. Unnecessary re-renders: UserList, UserDetail, and Footer don't use searchQuery. They re-render only because their parent did.
3. Fix without React.memo — state colocation:
function App() {
const [selectedId, setSelectedId] = useState(null);
return (
<div>
<SearchSection /> {/* Manages its own query state */}
<UserList selectedId={selectedId} onSelect={setSelectedId} />
<UserDetail userId={selectedId} />
<Footer />
</div>
);
}
function SearchSection() {
const [searchQuery, setSearchQuery] = useState('');
return <SearchBar value={searchQuery} onChange={setSearchQuery} />;
}Now typing only re-renders SearchSection and its children. UserList, UserDetail, and Footer are unaffected because App doesn't re-render.
If SearchBar needs to share its query with UserList (to filter users), that changes the equation — now the state must be at or above the common ancestor. In that case, React.memo on UserDetail and Footer is appropriate.
Quiz
Key Rules
- 1Three things trigger re-renders: setState, parent re-render, and context change. Props do NOT trigger re-renders independently.
- 2When a parent re-renders, ALL children re-render — even those whose props didn't change. This is React's default behavior.
- 3React.memo adds a shallow prop comparison before rendering. Only use it when profiling shows a component re-renders frequently with unchanged props.
- 4Context changes re-render ALL consumers of that context, even if they only use a subset of the context value.
- 5Re-rendering (calling component functions) is different from DOM updating. React re-renders broadly, then commits only the changed DOM nodes.
- 6The best optimization is avoiding unnecessary re-renders at the source: colocate state close to where it's used.