How does React bailout work in reconciliation?
Demo
Open this demo link, there is the famous button which increments when clicked.
Open the dev console you can filter the logs of render component
.
You can see the React code in the Elements tab.
The structure is simple:
<A><B><C><button/><D/></C></B><E><F/></E></A>
<A><B><C><button/><D/></C></B><E><F/></E></A>
Now click the button, as we talked before in our video series, setState
actually triggers reconciliation from the root, theoretically all the components should be rerendered, but we only see rerender for C and D
lanes & childlanes
Clear the filter in dev console, you should be able to see the logs I’ve already put there, you can click them to see the source code.
We can see a bunch of setting of lanes
and childLanes
after button is clicked.
In dispatchSetState()
which is the setCount()
in our react code, we can find the call of scheduleUpdateOnFiber()
(source)
js
export function scheduleUpdateOnFiber(fiber: Fiber,lane: Lane,eventTime: number,): FiberRoot | null {checkForNestedUpdates();const root = markUpdateLaneFromFiberToRoot(fiber, lane);if (root === null) {return null;}// Mark that the root has a pending update.markRootUpdated(root, lane, eventTime);...}
js
export function scheduleUpdateOnFiber(fiber: Fiber,lane: Lane,eventTime: number,): FiberRoot | null {checkForNestedUpdates();const root = markUpdateLaneFromFiberToRoot(fiber, lane);if (root === null) {return null;}// Mark that the root has a pending update.markRootUpdated(root, lane, eventTime);...}
Yeah, we’ve already found it, markUpdateLaneFromFiberToRoot()
. (source)
it does two things
- set
lanes
of target fiber, to mark itself has work to do - set
childLanes
of all ancestor fibers, to mark that its children have work to do.
Now if we are to draw a fiber graph after clicking the button, and including lanes
and childLanes
, it would be like this (first number is childLanes
)
performUnitOfWork()
scheduleUpdateOnFiber()
would schedule a reconciliation callback through ensureRootIsScheduled()
, which simply speaking, keep running performUnitOfWork()
on all the fiber nodes. (source)
js
function workLoopConcurrent() {// Perform work until Scheduler asks us to yieldwhile (workInProgress !== null && !shouldYield()) {performUnitOfWork(workInProgress);}}
js
function workLoopConcurrent() {// Perform work until Scheduler asks us to yieldwhile (workInProgress !== null && !shouldYield()) {performUnitOfWork(workInProgress);}}
shouldYield()
is another topic about expiration which we will cover in the future. For now let’s just focus on performUnitOfWork()
.
In it , beginWork()
has the real logic of checking if we can stop earlier(bailout) or not. (source)
function beginWork(current: Fiber | null,workInProgress: Fiber,renderLanes: Lanes,): Fiber | null {if (current !== null) {const oldProps = current.memoizedProps;const newProps = workInProgress.pendingProps;if (oldProps !== newProps ||hasLegacyContextChanged()) {// If props or context changed, mark the fiber as having performed work.// This may be unset if the props are determined to be equal later (memo).didReceiveUpdate = true;} else {// Neither props nor legacy context changes. Check if there's a pending// update or context change.const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(current,renderLanes,);if (!hasScheduledUpdateOrContext &&// If this is the second pass of an error or suspense boundary, there// may not be work scheduled on `current`, so we check for this flag.(workInProgress.flags & DidCapture) === NoFlags) {// No pending updates or context. Bail out now.didReceiveUpdate = false;return attemptEarlyBailoutIfNoScheduledUpdate(current,workInProgress,renderLanes,);}...}} else {...}workInProgress.lanes = NoLanes;switch (workInProgress.tag) {case FunctionComponent: {const Component = workInProgress.type;const unresolvedProps = workInProgress.pendingProps;const resolvedProps =workInProgress.elementType === Component? unresolvedProps: resolveDefaultProps(Component, unresolvedProps);return updateFunctionComponent(current,workInProgress,Component,resolvedProps,renderLanes,);}...}}
function beginWork(current: Fiber | null,workInProgress: Fiber,renderLanes: Lanes,): Fiber | null {if (current !== null) {const oldProps = current.memoizedProps;const newProps = workInProgress.pendingProps;if (oldProps !== newProps ||hasLegacyContextChanged()) {// If props or context changed, mark the fiber as having performed work.// This may be unset if the props are determined to be equal later (memo).didReceiveUpdate = true;} else {// Neither props nor legacy context changes. Check if there's a pending// update or context change.const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(current,renderLanes,);if (!hasScheduledUpdateOrContext &&// If this is the second pass of an error or suspense boundary, there// may not be work scheduled on `current`, so we check for this flag.(workInProgress.flags & DidCapture) === NoFlags) {// No pending updates or context. Bail out now.didReceiveUpdate = false;return attemptEarlyBailoutIfNoScheduledUpdate(current,workInProgress,renderLanes,);}...}} else {...}workInProgress.lanes = NoLanes;switch (workInProgress.tag) {case FunctionComponent: {const Component = workInProgress.type;const unresolvedProps = workInProgress.pendingProps;const resolvedProps =workInProgress.elementType === Component? unresolvedProps: resolveDefaultProps(Component, unresolvedProps);return updateFunctionComponent(current,workInProgress,Component,resolvedProps,renderLanes,);}...}}
just from the code we can roughly know what is being done here
- if props and context changes, then we should continue
didReceiveUpdate = true
- if not, then we check if there is scheduled updates by
checkScheduledUpdateOrContext()
- if no scheduled updates, then we try to bailout by
attemptEarlyBailoutIfNoScheduledUpdate
- when update is needed,
updateFunctionComponent()
is called for functional components
One thing to notice is that the return value of beginWork()
decides the next step of performUnitOfWork()
. If it is null, meaning we should stop and finish. (source)
checkScheduledUpdateOrContext()
is simple, just checks lanes
js
function checkScheduledUpdateOrContext(current: Fiber,renderLanes: Lanes,): boolean {const updateLanes = current.lanes;if (includesSomeLane(updateLanes, renderLanes)) {return true;}...}
js
function checkScheduledUpdateOrContext(current: Fiber,renderLanes: Lanes,): boolean {const updateLanes = current.lanes;if (includesSomeLane(updateLanes, renderLanes)) {return true;}...}
in checkScheduledUpdateOrContext()
, bailoutOnAlreadyFinishedWork()
is called, and childLanes
is checked. (source)
So things are clear now.
- basically React goes to every fiber, from root to all the fibers
- but if some fibers has no props changes, no context change, and both
lanes
andchildLanes
as 0, it bails out
Go back to our dev console, you can understand why A B E F are not rerendered.
A and B: try to bailout in updateFunctionComponent()
(source) since no update found, so no rerender. But their children C has work to do, so continue to C
E: bailout at beginWork()
F: since bailout at E, F is not checked at all.
Wait, why D is rerenderd?
Good question.
This is because <D/>
is in C
which means when C
is rerendered, it creates a new element of D, and leads to props change.
Let’s explain it in more details.
in beginWork()
if some work on C
is found, updateFunctionComponent()
is triggered since C
is functional component.
To update a functional component, first we execute (rerender) it to get the new element and then reconcileChilren. (source)
js
nextChildren = renderWithHooks(current,workInProgress,Component,nextProps,context,renderLanes);reconcileChildren(current, workInProgress, nextChildren, renderLanes);
js
nextChildren = renderWithHooks(current,workInProgress,Component,nextProps,context,renderLanes);reconcileChildren(current, workInProgress, nextChildren, renderLanes);
In our case, children is an array of button
and D
, finally it goes to reconcileChildrenArray()
. (source)
In it, we can see the code of updating the new fiber array.
js
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {const newFiber = updateSlot(returnFiber,oldFiber,newChildren[newIdx],lanes,);...
js
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {const newFiber = updateSlot(returnFiber,oldFiber,newChildren[newIdx],lanes,);...
Drill down to updateSlot()
then we go to updateElement()
(source)
In updateElement()
, below function is used to create(or reuse) a fiber
js
function useFiber(fiber: Fiber, pendingProps: mixed): Fiber {// We currently set sibling to null and index to 0 here because it is easy// to forget to do before returning it. E.g. for the single child case.const clone = createWorkInProgress(fiber, pendingProps);clone.index = 0;clone.sibling = null;return clone;}
js
function useFiber(fiber: Fiber, pendingProps: mixed): Fiber {// We currently set sibling to null and index to 0 here because it is easy// to forget to do before returning it. E.g. for the single child case.const clone = createWorkInProgress(fiber, pendingProps);clone.index = 0;clone.sibling = null;return clone;}
Go to createWorkInProgress
, we see the code where pendingProps
is used.
js
workInProgress = createFiber(current.tag,pendingProps,current.key,current.mode,);// orworkInProgress.pendingProps = pendingProps;
js
workInProgress = createFiber(current.tag,pendingProps,current.key,current.mode,);// orworkInProgress.pendingProps = pendingProps;
Yep, <D/>
is created every time when C()
is executed, pendingProps
would be different every time, though equal in value but not the same object.
So in beginWork()
, it is treated as an update since oldProps
is not newProps
.
js
if (oldProps !== newProps ||hasLegacyContextChanged()) {didReceiveUpdate = true;}
js
if (oldProps !== newProps ||hasLegacyContextChanged()) {didReceiveUpdate = true;}
Move children to props leads to bailout
From the above analysis, we also understand why moving <D/>
to children in props of C
would leads to bailout on D.
Here is code change.
diff
function C({children}) {console.log('render component C')const [count, setCount] = React.useState(0)const increment = React.useCallback(() => setCount(count => count + 1), [])- return <div className="component" data-name="C"><button onClick={increment}>{count}</button><D/></div>+ return <div className="component" data-name="C"><button onClick={increment}>{count}</button>{children}</div>}function A() {console.log('render component A')- return <div className="component" data-name="A"><B><C></C></B><E><F/></E></div>+ return <div className="component" data-name="A"><B><C><D/></C></B><E><F/></E></div>}
diff
function C({children}) {console.log('render component C')const [count, setCount] = React.useState(0)const increment = React.useCallback(() => setCount(count => count + 1), [])- return <div className="component" data-name="C"><button onClick={increment}>{count}</button><D/></div>+ return <div className="component" data-name="C"><button onClick={increment}>{count}</button>{children}</div>}function A() {console.log('render component A')- return <div className="component" data-name="A"><B><C></C></B><E><F/></E></div>+ return <div className="component" data-name="A"><B><C><D/></C></B><E><F/></E></div>}
Go to our second demo link, again open the console and click the button, we can see D is not rerendered this time.
Why? Simple.
Because when C()
is executed, children
is passed in as an argument, which means in createWorkInProgress()
, pendingProps
is exactly the same, thus bailout happens.
Want to know more about how React works internally?
Check out my series - React Internals Deep Dive!