#Why components re-render
A render is React calling your component function to get fresh JSX. It is not the same as touching the DOM: after a render, React compares the new JSX with the previous one (reconciliation, see The Virtual DOM and reconciliation) and only changes the DOM nodes that actually differ. So a render is usually cheap, and "unnecessary re-renders" only matter when there are a lot of them or the components are slow.
A component renders for exactly these reasons:
- Its own state changed (
useState/useReducerset to a new value). - Its parent rendered. This is the default and the one that surprises people: when a component renders, React renders every child it returns, all the way down, whether or not their props changed.
- A context it reads changed (
useContext/use(SomeContext)), even if its parent didn't render. - It's the first render (mount), or it was remounted because its
keyor its position in the tree changed.
A good mental model is a waterfall: a state change starts at the component that owns the state and flows down through everything below it. Props don't start re-renders; they're carried along by the water. To stop the flow at some point you need an explicit "dam" — React.memo (or the React Compiler), which says "skip me if my props are the same as last time".
import { useState } from "react";
function Child() {
console.log("Child rendered");
return <p>I take no props</p>;
}
export function Parent() {
const [count, setCount] = useState(0);
console.log(`Parent rendered with ${count}`);
return (
<>
<button onClick={() => setCount((c) => c + 1)}>Clicked {count}</button>
<Child />
</>
);
}
// Mount, then two clicks — the logged order (from the test):
// Parent 0, Child, Parent 1, Child, Parent 2, Child- On mount React calls
Parent(countis0), which returns a<button>and a<Child />element, so React callsChildtoo:Parent 0,Child. - The first click calls
setCount((c) => c + 1). That schedules a re-render of the component that owns the state,Parent, withcount0 → 1. Parentruns again and returns a brand-new<Child />element.Childisn't memoized, so React calls it again:Parent 1,Child.Childhas no props at all — it re-renders purely because its parent did.- React compares the
Childoutput (<p>I take no props</p>) with the previous one, finds no difference and leaves that<p>in the DOM alone. Only the button's text node changes. The re-render cost was one function call, not a DOM update. - The second click repeats it:
Parent 2,Child. This is the waterfall: state changes flow down to every descendant unless something stops them.
Before reaching for memoisation, two structural fixes stop the waterfall for free. Move state down into the smallest component that needs it, so fewer components sit below it. Or pass the slow part in as children: elements created by a component's parent don't re-render when the component's own state changes.
import { useState } from "react";
function SlowTree() {
console.log("SlowTree rendered");
return <p>slow</p>;
}
function ColourPicker({ children }) {
const [colour, setColour] = useState("red");
return (
<div style={{ color: colour }}>
<button onClick={() => setColour(colour === "red" ? "blue" : "red")}>{colour}</button>
{children}
</div>
);
}
export function App() {
return (
<ColourPicker>
<SlowTree />
</ColourPicker>
);
}
// Mount + two colour changes: "SlowTree rendered" is logged once (test: ["SlowTree"])Apprenders and creates the<SlowTree />element. It hands it toColourPickeras thechildrenprop. The element object belongs toApp's render.- Clicking the button sets
colour"red" → "blue", which re-rendersColourPickeronly.Appdoesn't re-render, because its state didn't change. ColourPickerreturns{children}— the same element objectAppcreated earlier, not a new one. When React sees the identical element in the same place, it knows nothing about it could have changed and skipsSlowTreeentirely.- So
SlowTreerenders once on mount and never again, without anymemo. The test logged["SlowTree"]after two clicks. - If you moved
<SlowTree />insideColourPicker's JSX instead, every colour change would create a new element andSlowTreewould render each time — exactly theChildcase above.
Setting state to the same value (compared with Object.is) is a bail-out: React skips the re-render. In the test, a button that called setV("a") when v was already "a" produced no extra renders. (React's docs warn it may still call the component once before bailing out, so don't rely on it for correctness.)
Two more causes behave differently from a normal re-render.
import { createContext, memo, useContext, useState } from "react";
function Field({ label }) {
const [text, setText] = useState("");
return <input aria-label={label} value={text} onChange={(e) => setText(e.target.value)} />;
}
export function KeyDemo() {
const [user, setUser] = useState("ann");
return (
<>
<button onClick={() => setUser("bob")}>switch</button>
<Field key={user} label={user} />
</>
);
}
const ThemeContext = createContext("light");
function Leaf() {
const theme = useContext(ThemeContext);
console.log(`Leaf ${theme}`);
return <p>{theme}</p>;
}
const MemoMiddle = memo(function Middle() {
console.log("Middle");
return <Leaf />;
});
export function ThemeApp() {
const [theme, setTheme] = useState("light");
return (
<ThemeContext value={theme}>
<button onClick={() => setTheme("dark")}>dark</button>
<MemoMiddle />
</ThemeContext>
);
}
// KeyDemo: type "hi", click switch → the "bob" input is empty
// ThemeApp: mount, click dark → Middle, Leaf light, Leaf dark- In
KeyDemo, typing "hi" rendersFieldwithtext"" → "h" → "hi"(logged as three renders of theannfield). - Clicking "switch" changes
keyfrom"ann"to"bob". A different key means "a different component": React unmounts the oldField, throwing away its state, and mounts a new one. The test sawField bob text=""— the "hi" is gone. That's a remount, not a re-render, and it's a deliberate tool for resetting state. - In
ThemeApp, clicking "dark" re-rendersThemeApp.MemoMiddleis memoized and has no props, so it's skipped —Middleis not logged again. - But
LeafreadsThemeContext, and its value changed"light" → "dark". React finds every consumer of that context and re-renders it directly, jumping over the memoized parent:Leaf darkis logged. - So
memostops the parent-render waterfall but never stops context or a component's own state. That's why big context values cause wide re-renders (see Context best practices and re-renders).
Parent holds const [n, setN] = useState(0) and renders <Child label="hi" />. Child is a plain function component (no memo). You click a button that calls setN(n + 1). Does Child render again?
Show answer
Yes.
setN(n + 1)changesParent's state, soParentrenders.- Rendering
Parentcreates a new<Child label="hi" />element. React doesn't compare props for plain components — it simply callsChildagain. - The
labelprop is the same string, but that only matters to a memoized component. WrapChildinmemoand it would be skipped, because"hi" === "hi". - Either way the DOM isn't touched, because
Child's output is identical. "Re-render" means "function called", not "DOM updated".