Skip to content

What Triggers a Re-render

advanced12 min read

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

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:

  1. setState — someone pushed the boulder (explicit state change)
  2. Parent re-rendered — a boulder from above hit this component
  3. 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>
  );
}
Info

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.

Execution Trace
Trigger
setTheme('dark')
App schedules a re-render
Render App
App() called
Returns new element tree with theme='dark'
Render Header
Header() called
Props unchanged, but parent rendered → child renders
Render Sidebar
Sidebar() called
Same — parent rendered, child follows
Render MainContent
MainContent() called
Same — cascading re-render
Render Footer
Footer() called
Same — every child of App re-renders
Reconcile
Diff old vs new tree
React finds minimal DOM changes. Most children produce identical output → no DOM mutations
Commit
Apply DOM changes
Only theme-related DOM attributes change. But ALL components were called

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."
Common Trap

"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:

  1. 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 doWhat 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

Quiz
Component B is a child of Component A. Component A re-renders. Component B receives the exact same props as the previous render. Does Component B re-render?
Quiz
What is the actual causation chain when a child component receives new props?

Key Rules

Key Rules
  1. 1Three things trigger re-renders: setState, parent re-render, and context change. Props do NOT trigger re-renders independently.
  2. 2When a parent re-renders, ALL children re-render — even those whose props didn't change. This is React's default behavior.
  3. 3React.memo adds a shallow prop comparison before rendering. Only use it when profiling shows a component re-renders frequently with unchanged props.
  4. 4Context changes re-render ALL consumers of that context, even if they only use a subset of the context value.
  5. 5Re-rendering (calling component functions) is different from DOM updating. React re-renders broadly, then commits only the changed DOM nodes.
  6. 6The best optimization is avoiding unnecessary re-renders at the source: colocate state close to where it's used.
1/11