How React Handles State and Prop Updates
In my earlier piece Inside Fiber: in-depth overview of the new reconciliation algorithm in React I established the groundwork for understanding the update mechanics we’ll explore here.
I covered the essential data structures and concepts that will come into play — Fiber nodes, the current and work-in-progress trees, side-effects, and the effects list. I also gave a bird’s-eye view of the main algorithm and distinguished between the render and commit stages. If that material isn’t fresh, I’d suggest starting there.
The demo app I introduced features a button that increments a displayed number:

You can try it out here. The component is straightforward — its render method produces two children: a button and a span. Clicking the button triggers a state update inside the event handler, which causes the text in the span to change:
class ClickCounter extends React.Component {
constructor(props) {
super(props);
this.state = {count: 0};
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
this.setState((state) => {
return {count: state.count + 1};
});
}
componentDidUpdate() {}
render() {
return [
<button key="1" onClick={this.handleClick}>Update counter</button>,
<span key="2">{this.state.count}</span>
]
}
}
I’ve also attached the componentDidUpdate lifecycle method to demonstrate how React queues effects that invoke it during the commit stage.
This article walks through how React processes state updates and constructs the effects list. We’ll examine what occurs within the high-level functions driving both the render and commit stages.
Specifically, we’ll observe how, in [completeWork](https://github.com/facebook/react/blob/cbbc2b6c4d0d8519145560bd8183ecde55168b12/packages/react-reconciler/src/ReactFiberCompleteWork.js#L532), React:
- modifies the
countproperty within thestateofClickCounter - invokes the
rendermethod to obtain children and runs the diff - refreshes the props for the
spanelement
And, in [commitRoot](https://github.com/facebook/react/blob/95a313ec0b957f71798a69d8e83408f40e76765b/packages/react-reconciler/src/ReactFiberScheduler.js#L523), React:
- sets the
textContentproperty on thespanelement - triggers the
componentDidUpdatelifecycle hook
Before diving in, let’s briefly see how work gets scheduled when we invoke setState within a click handler.
Bear in mind that this knowledge isn’t required for using React. We’re purely exploring its internals.
Work Scheduling
Upon clicking the button, the click event fires and React executes the callback we passed as a prop. In our case, that callback increments the counter and modifies the state:
class ClickCounter extends React.Component {
...
handleClick() {
this.setState((state) => {
return {count: state.count + 1};
});
}
}
Every React component carries an updater that bridges components and the React core. This abstraction lets setState take different forms in ReactDOM, React Native, server-side rendering, and test utilities.
We’ll focus on the updater implementation used in ReactDOM, which relies on the Fiber reconciler. For ClickCounter, that’s the [classComponentUpdater](https://github.com/facebook/react/blob/6938dcaacbffb901df27782b7821836961a5b68d/packages/react-reconciler/src/ReactFiberClassComponent.js#L186). It handles retrieving the Fiber instance, queueing updates, and scheduling work.
When updates get queued, they’re appended to a list on the Fiber node. In our scenario, the Fiber node linked to ClickCounter ends up looking like:
{
stateNode: new ClickCounter,
type: ClickCounter,
updateQueue: {
baseState: {count: 0}
firstUpdate: {
next: {
payload: (state) => { return {count: state.count + 1} }
}
},
...
},
...
}
Notice that the function stored at updateQueue.firstUpdate.next.payload corresponds to the callback supplied to setState. This marks the initial update slated for processing during the render stage.
Handling Updates for the ClickCounter Fiber
My previous discussion of the work loop explained the purpose of the nextUnitOfWork variable. It holds a reference to the Fiber node in the workInProgress tree that still requires attention. As React walks the Fiber tree, it consults this variable to determine whether further nodes have pending work.
Let’s say setState gets invoked. React takes the callback from ****setState**** and appends it to the updateQueue of the ClickCounter Fiber node, then schedules work. React moves into the render stage, starting its traversal from the topmost HostRoot Fiber using the renderRoot function. It skips over Fiber nodes that have already been processed until it locates one with pending work. Right now, only the ClickCounter Fiber node qualifies.
All processing happens on a cloned representation of this Fiber, stored in the alternate field. If that clone doesn’t exist yet, React generates it within createWorkInProgress before handling updates. We’ll assume nextUnitOfWork points to this alternate ClickCounter Fiber.
beginWork
The Fiber enters the beginWork function first.
Since this function runs for every Fiber node in the tree, it’s handy for placing a breakpoint when debugging the
****render****stage. I frequently do this, examining the Fiber’s type to single it out.
beginWork is essentially a large switch that identifies the necessary work based on the Fiber’s tag and delegates to the appropriate handler. For CountClicks, which is a class component, we take this path:
function beginWork(current$$1, workInProgress, ...) {
...
switch (workInProgress.tag) {
...
case FunctionalComponent: {...}
case ClassComponent:
{
...
return updateClassComponent(current$$1, workInProgress, ...);
}
case HostComponent: {...}
case ...
}
That leads us into [updateClassComponent](https://github.com/facebook/react/blob/1034e26fe5e42ba07492a736da7bdf5bf2108bc6/packages/react-reconciler/src/ReactFiberBeginWork.js#L428). Depending on whether this is a fresh mount, a resume, or a standard update, React either constructs an instance and mounts the component or proceeds with an update:
function updateClassComponent(current, workInProgress, Component, ...) {
...
const instance = workInProgress.stateNode;
let shouldUpdate;
if (instance === null) {
...
// In the initial pass we might need to construct the instance.
constructClassInstance(workInProgress, Component, ...);
mountClassInstance(workInProgress, Component, ...);
shouldUpdate = true;
} else if (current === null) {
// In a resume, we'll already have an instance we can reuse.
shouldUpdate = resumeMountClassInstance(workInProgress, Component, ...);
} else {
shouldUpdate = updateClassInstance(current, workInProgress, ...);
}
return finishClassComponent(current, workInProgress, Component, shouldUpdate, ...);
}
Processing Updates for the ClickCounter Fiber
Since the ClickCounter instance already exists, we move into [updateClassInstance](https://github.com/facebook/react/blob/6938dcaacbffb901df27782b7821836961a5b68d/packages/react-reconciler/src/ReactFiberClassComponent.js#L976). This is where React handles the bulk of work for class components. Key operations unfold in this order:
- invoke the deprecated
UNSAFE_componentWillReceiveProps****()****hook - process updates from
updateQueueto derive new state - pass that state to
getDerivedStateFromPropsand capture the output - run
shouldComponentUpdateto check whether an update is warranted;
if it returnsfalse, the entire render process — including callingrenderon this component and its children — gets skipped; otherwise, updates continue - call the deprecated
UNSAFE_componentWillUpdate - schedule an effect to invoke
componentDidUpdate
The effect triggering
componentDidUpdategets attached during therenderstage, but the actual call happens in thecommitstage that follows.
- refresh the
stateandpropsheld on the component instance
It’s crucial that state and props on the instance reflect the latest values before render executes, since that method’s output typically hinges on them. Without this, render would continue returning identical results.
Here’s a simplified view of that function:
function updateClassInstance(current, workInProgress, ctor, newProps, ...) {
const instance = workInProgress.stateNode;
const oldProps = workInProgress.memoizedProps;
instance.props = oldProps;
if (oldProps !== newProps) {
callComponentWillReceiveProps(workInProgress, instance, newProps, ...);
}
let updateQueue = workInProgress.updateQueue;
if (updateQueue !== null) {
processUpdateQueue(workInProgress, updateQueue, ...);
newState = workInProgress.memoizedState;
}
applyDerivedStateFromProps(workInProgress, ...);
newState = workInProgress.memoizedState;
const shouldUpdate = checkShouldComponentUpdate(workInProgress, ctor, ...);
if (shouldUpdate) {
instance.componentWillUpdate(newProps, newState, nextContext);
workInProgress.effectTag |= Update;
workInProgress.effectTag |= Snapshot;
}
instance.props = newProps;
instance.state = newState;
return shouldUpdate;
}
The snippet omits some auxiliary logic. For example, before calling lifecycle methods or adding their effects, React checks the typeof to see if the method exists. Here’s how it verifies componentDidUpdate before queueing the effect:
if (typeof instance.componentDidUpdate === 'function') {
workInProgress.effectTag |= Update;
}
Now we understand the operations applied to the ClickCounter Fiber during the render stage. Let’s observe how these impact the Fiber’s values. Initially, the Fiber for ClickCounter appears as:
{
effectTag: 0,
elementType: class ClickCounter,
firstEffect: null,
memoizedState: {count: 0},
type: class ClickCounter,
stateNode: {
state: {count: 0}
},
updateQueue: {
baseState: {count: 0},
firstUpdate: {
next: {
payload: (state, props) => {…}
}
},
...
}
}
Once processing finishes, the Fiber ends up like this:
{
effectTag: 4,
elementType: class ClickCounter,
firstEffect: null,
memoizedState: {count: 1},
type: class ClickCounter,
stateNode: {
state: {count: 1}
},
updateQueue: {
baseState: {count: 1},
firstUpdate: null,
...
}
}
Pause here and compare the property values.
With the update applied, count becomes 1 in both memoizedState and baseState within updateQueue. The component instance’s state has also been refreshed.
At this point, the queue holds no more updates, so firstUpdate is null. More notably, the effectTag has shifted from 0 to ****4****. In binary, that’s 100, with the third bit set — precisely the bit for the Update side-effect tag:
export const Update = 0b00000000100;
In summary, while working on the parent ClickCounter Fiber, React invokes pre-mutation lifecycle methods, refreshes state, and marks relevant side-effects.
Reconciling Children for the ClickCounter Fiber
Next, React steps into finishClassComponent. This is where React executes the render method on the instance and applies its diffing logic to the returned children. A high-level explanation is available in the official docs. The key point:
When comparing two React DOM elements of the same type, React examines both elements’ attributes, preserves the existing DOM node, and updates only the changed attributes.
Looking closer, it turns out React compares Fiber nodes against React elements. I won’t elaborate now since the process is intricate. A dedicated article on child reconciliation is on the way.
If you’re eager to explore yourself, look at the reconcileChildrenArray function, given that our
rendermethod returns an array of React Elements.
Two insights matter here. First, during child reconciliation, React creates or updates Fiber nodes for the child React elements produced by render. The finishClassComponent function hands back the reference to the current Fiber’s first child, which gets assigned to nextUnitOfWork for later processing in the work loop. Second, React refreshes props on children as part of the parent’s work, drawing on data from the React elements returned by render.
For instance, the Fiber node tied to the span element looks like this before the ClickCounter children are reconciled:
{
stateNode: new HTMLSpanElement,
type: "span",
key: "2",
memoizedProps: {children: 0},
pendingProps: {children: 0},
...
}
Both memoizedProps and pendingProps have children set to 0. The React element returned from render for the span is structured as:
{
$$typeof: Symbol(react.element)
key: "2"
props: {children: 1}
ref: null
type: "span"
}
There’s a discrepancy between the props on the Fiber node and those on the React element. Inside the [****createWorkInProgress****](https://github.com/facebook/react/blob/769b1f270e1251d9dbdce0fcbd9e92e502d059b8/packages/react-reconciler/src/ReactFiber.js#L326) function, which builds alternate Fiber nodes, React transfers the updated props from the React element to the Fiber node.
Thus, after reconciling children for ClickCounter, the span Fiber’s pendingProps get refreshed to match the React element:
{
stateNode: new HTMLSpanElement,
type: "span",
key: "2",
memoizedProps: {children: 0},
pendingProps: {children: 1},
...
}
When React later processes the span Fiber, it copies these into memoizedProps and schedules effects for DOM updates.
That wraps up all the work done for the ClickCounter Fiber during the render stage. Since the button is the first child, it becomes the next nextUnitOfWork. React finds nothing to do there and shifts to its sibling, the span Fiber. As per the algorithm covered previously, this happens within completeUnitOfWork.
Processing Updates for the Span Fiber
Now nextUnitOfWork points to the alternate of the span Fiber, and React begins its work. Mirroring the steps for ClickCounter, we start with beginWork.
Our span is a HostComponent, so the switch takes this branch:
function beginWork(current$$1, workInProgress, ...) {
...
switch (workInProgress.tag) {
case FunctionalComponent: {...}
case ClassComponent: {...}
case HostComponent:
return updateHostComponent(current, workInProgress, ...);
case ...
}
This leads into [updateHostComponent](https://github.com/facebook/react/blob/cbbc2b6c4d0d8519145560bd8183ecde55168b12/packages/react-reconciler/src/ReactFiberBeginWork.js#L686). There’s a clear parallel with updateClassComponent for class components. Functional components route to updateFunctionComponent, and so on. All these live in [ReactFiberBeginWork.js](https://github.com/facebook/react/blob/1034e26fe5e42ba07492a736da7bdf5bf2108bc6/packages/react-reconciler/src/ReactFiberBeginWork.js).
Reconciling Children for the Span Fiber
In our example, nothing of consequence happens within updateHostComponent for the span.
Completing Work for the Span Fiber
Once beginWork wraps up, the node proceeds to completeWork. But first, React syncs memoizedProps on the span Fiber. Recall that during child reconciliation for ClickCounter, pendingProps on the span Fiber got updated:
{
stateNode: new HTMLSpanElement,
type: "span",
key: "2",
memoizedProps: {children: 0},
pendingProps: {children: 1},
...
}
So after beginWork finishes for the ****span**** Fiber, React copies pendingProps into memoizedProps:
function performUnitOfWork(workInProgress) {
...
next = beginWork(current$$1, workInProgress, nextRenderExpirationTime);
workInProgress.memoizedProps = workInProgress.pendingProps;
...
}
It then invokes completeWork, another sizable switch like the one in beginWork:
function completeWork(current, workInProgress, ...) {
...
switch (workInProgress.tag) {
case FunctionComponent: {...}
case ClassComponent: {...}
case HostComponent: {
...
updateHostComponent(current, workInProgress, ...);
}
case ...
}
}
Because the span Fiber is a HostComponent, it executes the [updateHostComponent](https://github.com/facebook/react/blob/cbbc2b6c4d0d8519145560bd8183ecde55168b12/packages/react-reconciler/src/ReactFiberBeginWork.js#L686) function. Here React:
- prepares the DOM updates
- queues them in the
updateQueueof thespanFiber - adds the effect for DOM updating
Before these steps, the span Fiber looks like this:
{
stateNode: new HTMLSpanElement,
type: "span",
effectTag: 0
updateQueue: null
...
}
After completion, it looks like this:
{
stateNode: new HTMLSpanElement,
type: "span",
effectTag: 4,
updateQueue: ["children", "1"],
...
}
Observe the changes in effectTag and updateQueue. The tag is now 4 instead of 0. In binary, that’s 100, with the third bit active — matching the Update side-effect tag. This is the sole action React must take for this node during the commit stage, with updateQueue holding the payload for that update.
With ClickCounter and its children processed, the render stage concludes. React assigns the completed alternate tree to finishedWork on FiberRoot. That tree is ready for flushing to the screen, either immediately or when the browser allots time.
Effects List
Our span node and ClickCounter both carry side-effects, so React links the span Fiber to the firstEffect property of HostFiber.
The effects list gets built inside [compliteUnitOfWork](https://github.com/facebook/react/blob/d5e1bf07d086e4fc1998653331adecddcd0f5274/packages/react-reconciler/src/ReactFiberScheduler.js#L999). A Fiber tree with effects to update the span text and trigger ClickCounter hooks looks like:

And the linear list of nodes with effects is:

Commit Phase
This stage starts with completeRoot. Before doing anything else, it nulls the finishedWork property on FiberRoot:
root.finishedWork = null;
In contrast to the initial render stage, the commit stage runs synchronously, so HostRoot can safely indicate that commit work has begun.
During the commit stage, React updates the DOM and triggers componentDidUpdate. It does this by walking the effects list compiled during the earlier render stage and applying them.
For our span and ClickCounter nodes, these effects were defined in the render stage:
{ type: ClickCounter, effectTag: 5 }
{ type: 'span', effectTag: 4 }
The effect tag for ClickCounter is 5, or 101 in binary, marking Update work — for class components, that corresponds to componentDidUpdate. The least significant bit is also set, indicating all work for this Fiber completed in the render stage.
The span effect tag is 4, or 100 in binary, defining host component DOM updates. For the span, React will modify its textContent.
Applying Effects
Let’s see how these effects get applied. The function [commitRoot](https://github.com/facebook/react/blob/95a313ec0b957f71798a69d8e83408f40e76765b/packages/react-reconciler/src/ReactFiberScheduler.js#L523), responsible for this, breaks down into three sub-functions:
function commitRoot(root, finishedWork) {
commitBeforeMutationLifecycles()
commitAllHostEffects();
root.current = finishedWork;
commitAllLifeCycles();
}
Each sub-function loops over the effects list, checks effect types, and applies those matching its purpose. In our scenario, that means invoking componentDidUpdate on ClickCounter and updating the span element’s text.
The first, commitBeforeMutationLifeCycles, scans for the [Snapshot](https://github.com/facebook/react/blob/b87aabdfe1b7461e7331abb3601d9e6bb27544bc/packages/shared/ReactSideEffectTags.js#L25) effect to call getSnapshotBeforeUpdate. Since ClickCounter doesn’t define that method, React didn’t add this effect during the render stage. So, this function is a no-op here.
DOM Updates
React proceeds to [commitAllHostEffects](https://github.com/facebook/react/blob/95a313ec0b957f71798a69d8e83408f40e76765b/packages/react-reconciler/src/ReactFiberScheduler.js#L376). This is where the span text changes from 0 to 1. The ClickCounter Fiber needs nothing here, as class component nodes don’t drive DOM updates.
This function picks the appropriate effect type and applies related operations. For updating the span text, we hit the Update branch:
function updateHostEffects() {
switch (primaryEffectTag) {
case Placement: {...}
case PlacementAndUpdate: {...}
case Update:
{
var current = nextEffect.alternate;
commitWork(current, nextEffect);
break;
}
case Deletion: {...}
}
}
Descending into commitWork, we eventually reach [updateDOMProperties](https://github.com/facebook/react/blob/8a8d973d3cc5623676a84f87af66ef9259c3937c/packages/react-dom/src/client/ReactDOMComponent.js#L326). It takes the updateQueue payload set during the render stage and updates the textContent on the span element:
function updateDOMProperties(domElement, updatePayload, ...) {
for (let i = 0; i < updatePayload.length; i += 2) {
const propKey = updatePayload[i];
const propValue = updatePayload[i + 1];
if (propKey === STYLE) { ...}
else if (propKey === DANGEROUSLY_SET_INNER_HTML) {...}
else if (propKey === CHILDREN) {
setTextContent(domElement, propValue);
} else {...}
}
}
After DOM updates finish, React assigns the finishedWork tree to HostRoot, swapping the alternate in as the current tree:
root.current = finishedWork;
Invoking Post-Mutation Lifecycle Hooks
The final piece is [****commitAllLifecycles****](https://github.com/facebook/react/blob/d5e1bf07d086e4fc1998653331adecddcd0f5274/packages/react-reconciler/src/ReactFiberScheduler.js#L479). Here, React calls post-mutation lifecycle methods. During the render stage, an Update effect was attached to ClickCounter. That’s among the effects commitAllLifecycles seeks, triggering componentDidUpdate:
function commitAllLifeCycles(finishedRoot, ...) {
while (nextEffect !== null) {
const effectTag = nextEffect.effectTag;
if (effectTag & (Update | Callback)) {
const current = nextEffect.alternate;
commitLifeCycles(finishedRoot, current, nextEffect, ...);
}
if (effectTag & Ref) {
commitAttachRef(nextEffect);
}
nextEffect = nextEffect.nextEffect;
}
}
This function also refreshes refs, though none exist in our case. The method call happens in [commitLifeCycles](https://github.com/facebook/react/blob/e58ecda9a2381735f2c326ee99a1ffa6486321ab/packages/react-reconciler/src/ReactFiberCommitWork.js#L351):
function commitLifeCycles(finishedRoot, current, ...) {
...
switch (finishedWork.tag) {
case FunctionComponent: {...}
case ClassComponent: {
const instance = finishedWork.stateNode;
if (finishedWork.effectTag & Update) {
if (current === null) {
instance.componentDidMount();
} else {
...
instance.componentDidUpdate(prevProps, prevState, ...);
}
}
}
case HostComponent: {...}
case ...
}
You’ll also spot that React invokes componentDidMount here for components rendering for the first time.
