Most React performance questions in interviews come down to one thing: do you know what a render is and what causes one? I have seen people memorize useMemo rules without ever being able to explain why a component ran again.
In this post I build the mental model from the ground up. You will learn the difference between render and commit, the three things that trigger a re-render, how React decides what to keep and what to throw away, and why index keys cause real bugs.
Render and commit are different things
When people say a component rendered, they usually mean React called the component function. That is only the first phase. React works in two phases, and keeping them apart clears up a lot of confusion.
In the render phase, React calls your components and collects the elements they return. This work is pure calculation, and it does not touch the DOM. In the commit phase, React applies the differences to the DOM and then runs effects.
- Triggerstate, parent, or context
- Rendercall components, get elements
- Reconcilediff against the previous tree
- Commitapply changes to the DOM
Only the commit phase touches the DOM. A render that produces the same output as before changes nothing on screen.
This is why a re-render is not automatically expensive or visible. A component can run many times while the DOM stays exactly the same. The cost is the function call and the diffing, not a page repaint.
What triggers a re-render
There are three triggers. A component re-renders when its own state changes, when its parent re-renders, or when a context it reads changes.
The second one surprises people. When a parent renders, React renders all of its children by default, whether or not their props changed. It does not compare props first.
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);
return (
<>
<button onClick={() => setCount(count + 1)}>
Clicked {count}
</button>
<Child /> {}
</>
);
}Child re-renders every time Parent does, even with no props. Try it below, and compare it with a child wrapped in memo.
Who re-renders when Parent does?
Press Count +1 or the switch. Each press changes Parent's state.
Plain. Re-renders with its parent.
Wrapped in memo, no props. Skips.
Press Count +1 and watch which child re-renders.
Skipping work with memo
You can tell React to skip a child when its props are shallowly equal to the last render. That is what memo does. It is an opt-out from the default behavior, not a different kind of rendering.
import { memo, useState } from "react";
const Child = memo(function Child({ label }: { label: string }) {
console.log("Child rendered");
return <p>{label}</p>;
});
export function Parent() {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount(count + 1)}>
Clicked {count}
</button>
<Child label="Static text" />
</>
);
}memo skips Child when its props are shallowly equal.
- Parentstate changed, renders
- Buttonnew output, renders
- Child (memo)same props, skipped
The muted step is skipped because its props are shallowly equal to the previous render.
The catch is that shallow equality compares by reference. If you pass a new object, array, or function on each render, the check fails and memo does nothing. That is the reason useMemo and useCallback exist.
Reconciliation: how React diffs
After rendering, React compares the new tree of elements with the previous one. It does not do a full tree comparison. It relies on two simple rules to stay fast.
- If the element type at a position changes (for example
divtosection, orAtoB), React tears down the old subtree and builds a new one. State inside is lost. - If the type is the same at the same position, React keeps the existing instance and updates its props. State is preserved.
Position matters because React has no other identity for a child in a plain tree. The same component at the same place in the tree is treated as the same component. This is also how you reset state on purpose: change the type, or give the element a different key.
Keys give list items an identity
For lists, position alone is not enough, because items get reordered, inserted, and removed. A key tells React which item is which across renders. Keys only need to be unique among siblings.
A good key is stable and comes from the data, like a database id. A random key generated during render is worse than none, because every item looks new on every render and gets remounted.
Why index keys break
Using the array index as a key is fine for a static list that never changes order. It breaks once items can move, because the index describes the position, not the item. React matches by key, so it keeps the instance at that index and just hands it different props.
That is harmless for pure display. It becomes a visible bug when each row owns local state, such as an uncontrolled input.
import { useState } from "react";
type Todo = { id: string; title: string };
export function TodoList({ initial }: { initial: Todo[] }) {
const [todos, setTodos] = useState(initial);
return (
<ul>
{todos.map((todo, index) => (
<li key={index}> {}
<span>{todo.title}</span>
<input placeholder="Add a note" />
<button
onClick={() =>
setTodos(todos.filter((t) => t.id !== todo.id))
}
>
Delete
</button>
</li>
))}
</ul>
);
}Type a note into the first row, then delete that first row. The titles update correctly, but the typed note now sits next to the second item. React saw key 0 still present, so it reused that row's input and its DOM value. The row at the end of the list is the one React removed.
import { useState } from "react";
type Todo = { id: string; title: string };
export function TodoList({ initial }: { initial: Todo[] }) {
const [todos, setTodos] = useState(initial);
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}> {}
<span>{todo.title}</span>
<input placeholder="Add a note" />
<button
onClick={() =>
setTodos(todos.filter((t) => t.id !== todo.id))
}
>
Delete
</button>
</li>
))}
</ul>
);
}With todo.id as the key, React ties each input to its item. Deleting a row removes that row's instance, and the other rows keep their own state. The same problem shows up when you insert at the start or reorder a list. See it for yourself:
Index keys vs stable ids
Type a note in the first row, then press Add or Shuffle. Does the note stay with its item?
- Applekey=0
- Bananakey=1
- Cherrykey=2
Index keys: the input stays at its position, so after a change the note can land on a different item.
The React Compiler
The React Compiler is a build-time tool that adds memoization for you. It analyzes your components and hooks, and inserts caching so that values and child renders are reused when their inputs have not changed. In many codebases that removes the need to write memo, useMemo, and useCallback by hand.
It does not change the rules above. Components still render in response to state, parent, and context, and keys still decide identity. The compiler works within that model, and it assumes your components follow the rules of React, such as being pure during render.
So I still care about this model. When something re-renders unexpectedly, or state resets when it should not, the answer is in render, reconcile, and commit. You cannot debug it by knowing a tool exists.
Interview answer in 30 seconds
Key takeaways
- Render calls components and is pure. Commit applies changes to the DOM.
- A re-render comes from state, a parent re-render, or a context change.
- A parent re-rendering renders its children by default, even with unchanged props.
memoskips a child only when props are shallowly equal.- Same type at the same position keeps state. A different type or key resets it.
- Use stable ids as keys. Index keys break with reordering, inserting, and deleting.
- The React Compiler memoizes for you, but the rendering model still applies.
