Skip to main content

React Re-rendering: State Updates Explained

Re-rendering is the heart of React. When state updates, React re-executes your component function to get a new UI description, compares it with the previous version, and updates only what changed. Understanding this cycle—the Render phase, Commit phase, and how React batches updates—is essential for building performant, bug-free applications.

What Is a React Re-Render and How Does It Work?

A re-render is React calling your component function again after state or props change. React doesn't throw away the entire DOM; instead, it compares the new Virtual DOM with the old one (the "diff") and surgically updates only changed elements. This comparison happens in a predictable pipeline with three phases.

The Trigger phase occurs when you call a state setter like setCount(count + 1). React queues an update. The Render phase is asynchronous; React walks the component tree from the updated component downward, calling each affected component function to create a new Virtual DOM. The Commit phase is synchronous and uninterruptible; React diffs the new Virtual DOM against the old, applies surgical DOM updates, and commits the changes. Finally, the Passive Effects phase runs useEffect cleanup and setup functions after the DOM is painted.

What Triggers a Component to Re-Render?

Four primary triggers cause re-renders:

State Changes: When you update state with a setter function (setCount(count + 1)), React queues a re-render. The component function is called again with the new state value:

import React, { useState } from 'react';

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

console.log('Counter is rendering with count =', count);

return (
<div>
<h1>Count: {count}</h1>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}

export default Counter;

Clicking the button calls setCount, queuing a re-render. React calls Counter again, sees the new count value, and updates the DOM.

Parent Re-renders: When a parent component re-renders, all children re-render by default, regardless of whether props changed:

import React, { useState } from 'react';

function Child() {
console.log('Child is rendering');
return <p>I am the child.</p>;
}

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

console.log('Parent is rendering with count =', count);

return (
<div>
<h1>Count: {count}</h1>
<button onClick={() => setCount(count + 1)}>
Increment Parent
</button>
<Child />
</div>
);
}

export default Parent;

Every time you click the button, both Parent and Child render. Child receives no new props, but it re-renders because its parent did. You'll see both console logs fire.

Context Changes: Components consuming a React Context re-render when the context value changes. We'll explore this in later chapters.

Hook Updates: Custom hooks with internal state can trigger re-renders in components that use them.

How Does React Batch State Updates?

React groups multiple state updates in the same event handler into a single re-render to optimize performance. Consider this example:

import React, { useState } from 'react';

function TripleCounter() {
const [number, setNumber] = useState(0);

function handleClickBad() {
// All three calls see 'number' as 0
setNumber(number + 1); // Queues: set to 0 + 1 = 1
setNumber(number + 1); // Queues: set to 0 + 1 = 1
setNumber(number + 1); // Queues: set to 0 + 1 = 1

// Final value: 1 (only the last call matters)
// Only ONE re-render happens
}

function handleClickGood() {
// Use updater functions to queue changes
setNumber(n => n + 1); // Queues: take pending state, add 1
setNumber(n => n + 1); // Queues: take pending state, add 1
setNumber(n => n + 1); // Queues: take pending state, add 1

// Final value: 3 (all three updates applied in sequence)
// Only ONE re-render happens, but with the correct final state
}

return (
<div>
<h2>Number: {number}</h2>
<button onClick={handleClickBad}>Increment (Wrong) — result: 1</button>
<button onClick={handleClickGood}>Increment (Right) — result: 3</button>
</div>
);
}

export default TripleCounter;

In handleClickBad, React batches the three setNumber calls, but because each one reads number as 0, all three queue the same value. React de-duplicates and the final state is 1.

In handleClickGood, each call passes a function that reads the pending state. React queues these functions and executes them in order during the render phase: 0 + 1 = 1, then 1 + 1 = 2, then 2 + 1 = 3. The final state is 3. This is the updater function pattern: setNumber(prevNum => prevNum + 1).

The Render and Commit Phases Explained

Render Phase (Asynchronous): React calls component functions to create a new Virtual DOM. This phase is interruptible and React may pause and resume it if higher-priority updates arrive. No DOM changes happen yet.

Commit Phase (Synchronous): React diffs the new Virtual DOM against the old, applies changes to the real DOM, and updates refs. This phase cannot be interrupted. After the commit, the browser paints the changes and users see them.

Passive Effects Phase: After the commit and paint, useEffect hooks run. If a dependency array changed, cleanup runs first, then the effect body.

Understanding this order is crucial for debugging: if you log something in the component body, it fires during Render. If you log in useEffect, it fires after Commit and paint.

Performance: Preventing Unnecessary Re-Renders

Most performance issues in React stem from unnecessary re-renders. If a component's output doesn't change, it shouldn't re-render. Techniques to prevent unnecessary re-renders include:

React.memo: Wraps a component to skip re-renders if props don't change. Use for expensive components that receive stable props.

useMemo and useCallback: Memoize values and functions to maintain referential equality across renders, preventing child re-renders caused by changing prop references.

Composition: Pass components as props (children prop) so they're defined outside the parent. They won't re-render when the parent does because they're not recreated each render.

Keep State Local: Place state as low as possible in the tree. If only a button needs to toggle expanded/collapsed, don't lift that state to the parent.

Best Practices for State Updates

Use Updater Functions for Dependent Updates: When multiple state updates depend on the previous state, use setState(prev => prev + 1) rather than setState(state + 1).

Lift State When Needed: If two siblings need to share state, lift it to their parent. Don't hoist unnecessarily, which causes the parent to re-render more often than needed.

Avoid Creating Components in Render: Never define a component function inside another component's body. It gets redefined on every render, destroying internal state and breaking performance.

// ANTI-PATTERN
function Parent() {
function Child() { // Redefined every render!
return <p>Child</p>;
}
return <Child />;
}

// CORRECT
function Child() { // Defined once at module scope
return <p>Child</p>;
}

function Parent() {
return <Child />;
}

Anti-Patterns to Avoid

Do Not Call setState in the Component Body: Calling setCount(count + 1) directly in the render body causes infinite re-renders. Call it in event handlers or useEffect.

Do Not Mutate State Directly: State is immutable. Use setter functions to create new values. Direct mutation won't trigger re-renders.

Do Not Create Multiple Children Components Dynamically: Each render creates new component instances if the function is redefined, losing state and performance.

Key Takeaways

  • Re-renders occur when state changes, a parent re-renders, context changes, or a hook updates. React then calls the component function again to get the new UI description.
  • The render pipeline has three phases: Render (asynchronous, creates Virtual DOM), Commit (synchronous, applies DOM changes), and Passive Effects (runs useEffect hooks after paint).
  • React batches state updates in event handlers into a single re-render. Use updater functions (setState(prev => prev + 1)) to queue dependent updates correctly.
  • Children re-render when their parent re-renders, even if props didn't change. Use React.memo, composition, and careful state placement to prevent unnecessary re-renders.
  • Memoization techniques (React.memo, useMemo, useCallback) and composition patterns are essential for performance optimization.

Frequently Asked Questions

Why does my child component re-render even though its props didn't change?

When the parent re-renders, React re-renders all children by default. To prevent this, wrap the child in React.memo if its props are stable, or use composition (pass the child as a prop) so it's created outside the parent.

How do I know if a re-render is happening?

Add console.log at the top of your component function. Every log output means a re-render occurred. Use React DevTools Profiler to measure render times and identify slow components.

Can I have zero re-renders after initial mount?

No. React re-renders when state, props, or context change. However, you can minimize re-renders with memoization and composition. If a component's state and props never change, it won't re-render.

What is the difference between state updates in event handlers and in useEffect?

State updates in event handlers are batched synchronously and commit before the next paint. State updates in useEffect are batched too, but they trigger a second render cycle. Use event handlers for immediate updates; use useEffect for side effects (API calls, subscriptions).

How does React.memo affect the render cycle?

React.memo does a shallow prop comparison. If props are identical to the previous render, it skips the re-render. If props reference different objects (even if values are the same), it re-renders. Use with useMemo and useCallback to stabilize prop values.

Further Reading