Rendering vs Committing: What Actually Happens in React
A practical mental model for React’s render pipeline, and why 're-render' isn’t always the performance concern we may think it is.
function Counter() {
const [count, setCount] = useState(0);
console.log("render", count);
const onClickHandler = () => setCount((prevCount) => prevCount + 1);
return <button onClick={onClickHandler}>{count}</button>;
}
Using the famously simple counter example above we can see that this component would log out render <count> every time we click the button. So:
render 0
render 1
render 2
render 3
...
A common concern here is that the component is rendering every time we click the button and the common solution would be to use something like useMemo with React.memo to prevent unnecessary but here we need to take a moment to think, is this really an issue? Or is React just doing its job?
To know the answer to that question, we need to understand what that job is. For that, we should know that React has three main phases:
- The Trigger Phase - The action that caused React to start the render pipeline.
- The Render Phase - Pure calculation, it ‘thinks’ about what the UI should look like.
- The Commit Phase - Actually makes changes to the DOM
So really a re-render is just React through the render phase again and ‘thinking’ about what the UI should look like.
The Trigger Phase - Where is all starts
The most common actions that trigger a re-render are:
- State changes, like in the example above. Clicking the button changed the component state and caused a re-render.
- Prop changes, if we passed the
countstate into a child component then a re-render would also be trigger for that child component when we clicked the button due to its props changing. - Context Changes, if a context value is changed it will trigger a re-render for each component consuming that context value.
It is important to remember that this only triggers the render phase, if no value was actually changed then no changes to the DOM would be made.
The Render Phase - Deeper dive
When a render a triggered React calls the components to ask: “Given these props and this state, what should the UI look like now?”
In technical terms that means:
- React calls our function components, the
render()method in class components, and for each component it:- Reads the props and state.
- Runs any logic inside the component body.
- Returns the JSX.
- React then builds a fiber tree using the return values from all those component calls.
At high level, React keeps roughly two versions of our UI in memory:
- Current fiber tree - What is currently visible/committed on screen.
- Work in progress fiber tree - The tree being built from executing our components.
The process of building these trees, comparing each component/fiber created, and building an effect list is called reconciliation. It is an integral part of the render phase.
The effect list is a list of ‘things that need to change in the DOM later’.
It is important to note:
- No DOM mutations have happened yet.
- No
useEffecthooks have been run yet. - No
useLayoutEffecthooks have been run yet.
So with this knowledge we can better understand what kind of operations to include or omit from the body of our React components.
For example, it’s not a good idea to perform things like:
- Network Requests
- DOM reads/writes
- Logging to analytics
- Heavy computational tasks
This is why each of those are typically done in useEffect hooks or memoized computations.
In some cases these operations would be even earlier, like server cache for example.
Once React has calculated what changes need to be made it moves onto the commit phase.
The Commit Phase - When changes actually happen
This is where React will:
- Create/update/remove DOM nodes.
- Apply props, styles, and event handlers to DOM nodes.
- Run the effects:
useLayoutEffect: synchronous, before the browser paints.useEffect: asynchronous, after paint.
How does React do this?
Well first it uses the effect list it built in the render phase. This is a list of all the DOM changes that React calculated needed to happen. Once the list has been complete React will then execute any useLayoutEffect hooks, then the browser paint happens and finally the useEffect hooks.
## So.. Is a re-render bad or good?
Well, it depends. A re-render happens when React calls our component function again during the render phase because some props or state changed.
But this DOES NOT mean that:
- The DOM was re-created.
- Everything under that component was thrown away.
- The user saw anything nasty.
The commit phase only occurs if React actually calculated changes need to happen.
So if we keep our components well organized and keep expensive computations memoized, network calls in effects etc etc, the render phase is actually very quick and the user of our application would never know it even happened.
In these instances, a re-render is NOT a bad thing.
That doesn’t mean that all re-renders are ok though, it is important to understand when a re-render is causing issues and some common things we can look out for are:
- Janky or stuttering scrolling.
- Typing lag.
- Animations lagging or stuttering.
- DevTools profiler showing long commit times.
In these instances a re-render may be causing an issue in our application.
So how do we fix it?
High Level Optimization Toolbox
React.memo
const Row = React.memo(function Row({ item }: { item: Item }) {
return <div>{item.name}</div>;
});
Using React.memo allows us to skip the re-render of a pure component when its props haven’t changed.
useMemo
const filtered = useMemo(() => items.filter((item) => item.visible), [items]);
We can use the useMemo hook to cache the result of an expensive calculation. By providing a list of dependencies we can instruct React to only recalculate the value if one of those dependencies change.
useCallback
import React, { useCallback } from "react";
type Todo = { id: number; text: string; done: boolean };
const TodoItem = React.memo(function TodoItem({
todo,
onToggle,
}: {
todo: Todo;
onToggle: (id: number) => void;
}) {
console.log("render <TodoItem />", todo.id);
return (
<li>
<label>
<input
type="checkbox"
checked={todo.done}
onChange={() => onToggle(todo.id)}
/>
{todo.text}
</label>
</li>
);
});
export function TodoList({ todos }: { todos: Todo[] }) {
// ✅ Stable function identity between renders
const handleToggle = useCallback((id: number) => {
console.log("toggled", id);
}, []); // deps: [] → function never changes
return (
<ul>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} onToggle={handleToggle} />
))}
</ul>
);
}
Splitting Context and/or state
const AppContext = createContext<AppState>(/* everything */);
App wide context and state is generally a bad idea and can cause the entire tree, which is the whole app, to re-render whenever it changes.
Instead we should keep context and state as local as possible.
### The DevTools profiler
We should always aim to provide evidence for performance optimizations. By using the profiler we can clearing see how much time particular components take to render/commit. This gives us a baseline performance number to work with. Then after we apply our optimizations we can run the profiler again and measure whether our changes had the intended affect or not.
Conclusion
A re-render just means React re-ran that computation. That is completely normal, and is just React doing its job.
Performance problems don’t come from the existence of re-renders; they come from:
- Doing too much work in render.
- Putting work in the wrong phase (side effects in render).
- Structuring state so that a tiny change forces huge parts of the tree to re-render.
If the UI is smooth and our components are simple and pure, we don’t need to fear re-renders.
We shouldn’t fear re-renders; we should fear doing too much inside them.