ReactUnderstanding Re-renders

Understanding Re-renders

React beginners often fear re-renders. Experienced React developers understand them. Knowing exactly what triggers a re-render — and what does not — is the single most useful mental model for writing performant React applications.

What Triggers a Re-render?

A component re-renders for exactly four reasons:

  • State update — calling the setter from useState or useReducer inside or affecting this component

  • Parent re-render — when a parent component re-renders, all its children re-render by default, regardless of whether their props changed

  • Context update — when a context value changes, every consumer of that context re-renders

  • Force updateuseReducer dispatch with the same state, or calling forceUpdate in a class component

JSX
import { useState } from 'react'

function Parent() {
  const [count, setCount] = useState(0)

  // Every click re-renders Parent — AND re-renders Child,
  // even though Child receives no props at all.
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <Child />
    </div>
  )
}

function Child() {
  console.log('Child rendered')  // Fires every time Parent re-renders
  return <p>I am a child</p>
}
Warning
Parent re-render cascading is the most common source of unexpected re-renders. A state update high in the tree triggers renders all the way down through every child, grandchild, and great-grandchild.
What Does NOT Trigger a Re-render?
  • Ref changesref.current = newValue is a mutation that React never observes. No re-render.

  • Event handler changes — creating a new function on each render (without useCallback) does not trigger a re-render on its own; it only matters if passed as a prop to a memoized child

  • External variable mutations — mutating a non-state variable directly (e.g. myArray.push(item)) does not trigger a re-render

  • Same state value — if you call setState with the exact same value React bails out (Object.is comparison) and skips the re-render

JSX
import { useRef, useState } from 'react'

function Demo() {
  const renderCount = useRef(0)
  const [text, setText] = useState('')

  renderCount.current++  // mutation — does NOT cause a re-render

  // Calling setState with the same value bails out:
  const [flag, setFlag] = useState(false)
  const triggerBailout = () => setFlag(false)  // false → false, no re-render

  return (
    <div>
      <p>Renders: {renderCount.current}</p>
      <input value={text} onChange={e => setText(e.target.value)} />
    </div>
  )
}
The Render → Reconcile → Commit Pipeline

A "re-render" in React is actually three distinct phases:

  • Render — React calls your component function (or class render method) to get the new JSX description. This is pure — no side effects, no DOM mutations. In concurrent mode this may happen multiple times.

  • Reconcile (diffing) — React compares the new Virtual DOM tree with the previous one to compute the minimal set of changes.

  • Commit — React applies the DOM mutations, runs useLayoutEffect synchronously, lets the browser paint, then runs useEffect.

Note
When developers say "component re-renders", they usually mean the render phase only — calling your function. DOM mutations happen in the commit phase, and only if something actually changed. An expensive render function is costly; expensive DOM mutations are separate.
Visualizing a Component Tree Re-render

Imagine this component tree. When App state changes, the default behaviour without any memoization is:

Text
App (state changes)
├── Header            ← re-renders (parent re-rendered)
│   └── Logo          ← re-renders (parent re-rendered)
├── Sidebar           ← re-renders (parent re-rendered)
│   ├── NavItem       ← re-renders (parent re-rendered)
│   └── NavItem       ← re-renders (parent re-rendered)
└── MainContent       ← re-renders (parent re-rendered)
    ├── ArticleList   ← re-renders (parent re-rendered)
    │   └── Article   ← re-renders (parent re-rendered)
    └── Pagination    ← re-renders (parent re-rendered)

Every single node re-renders. With React.memo on stable components,
only the nodes whose props actually changed would re-render.
The "Renders Are Cheap" Philosophy

React's official guidance is that renders are intentionally cheap. A render is just a JavaScript function call that produces a plain object (the Virtual DOM). Modern JavaScript engines execute millions of such calls per second. The real cost is DOM mutation, which React minimises through diffing.

This means you should not optimise re-renders by default. The React team explicitly discourages wrapping everything in React.memo, useMemo, and useCallback pre-emptively — these optimisations have their own overhead (memory for cached values, equality comparisons) and make code harder to read.

When Renders Become Expensive

Renders become a real problem when:

  • The render function itself is slow — heavy computations, large array transformations, expensive formatting in the function body

  • Too many components re-render from a single update — a context at the root re-renders hundreds of subscribers

  • Render happens too frequently — a mousemove event firing 60 times/second triggering a large subtree

  • Render triggers heavy child mounts/unmounts — not just updates, but full lifecycle cycles

JSX
// Expensive render — sort runs on every re-render
function ProductList({ products, searchTerm }) {
  // This runs every render, even when products hasn't changed
  const filtered = products
    .filter(p => p.name.includes(searchTerm))
    .sort((a, b) => a.price - b.price)

  return filtered.map(p => <ProductCard key={p.id} product={p} />)
}

// Fixed — memoize the expensive computation
import { useMemo } from 'react'

function ProductList({ products, searchTerm }) {
  const filtered = useMemo(
    () =>
      products
        .filter(p => p.name.includes(searchTerm))
        .sort((a, b) => a.price - b.price),
    [products, searchTerm]  // only re-compute when these change
  )

  return filtered.map(p => <ProductCard key={p.id} product={p} />)
}
Context Updates

Context is a common source of surprise re-renders. Every time the context provider re-renders with a new value, every consumer component re-renders — even if the part of the value they use did not change.

JSX
// Problem: inline object creates a new reference every render
function App() {
  const [user, setUser] = useState({ name: 'Alice', role: 'admin' })
  const [theme, setTheme] = useState('dark')

  // This object is new on every App render, even if user hasn't changed
  // Every ThemeContext consumer re-renders unnecessarily
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <UserContext.Provider value={{ user, setUser }}>
        <Router />
      </UserContext.Provider>
    </ThemeContext.Provider>
  )
}

// Fix: split contexts so consumers only subscribe to what they need
// Fix: useMemo to stabilise the context value object
const themeValue = useMemo(() => ({ theme, setTheme }), [theme])
Re-renders Are Not Always Bad

A re-render does not mean the DOM changed. If the new Virtual DOM output is identical to the previous one, React commits nothing. The render phase ran, but the user sees no visual change and no DOM was touched.

Scenario

Re-render fires?

DOM update?

Performance impact

State changes

Yes

Only changed nodes

Low (usually)

Parent re-renders, same props

Yes (without memo)

No

Very low

Context updates

Yes (all consumers)

Only changed nodes

Medium

Same state value set

No

No

None

Ref mutation

No

No

None

Tip
Profile before you optimise. React DevTools Profiler tells you exactly which components render, how long each takes, and why they rendered. Guessing which renders are slow leads to premature optimisation — adding complexity with no real benefit.