[DIP] React Lifecycle Phase

Tech

It's already been several years since I started using React, and through many versions it has now reached v19. Every so often I'd think, "I should take a look at React's internal code," and I repeatedly attempted—and failed—to binge through the "React Deep Dive" series on the Deep Dive Magic Code | Goidle blog. This time I ended up studying with React Internals Deep Dive, and this time I want to keep the code close as I study and reorganize what I learn by category. While pondering how to study with the code close at hand, I set things up—similar to a monorepo—by cloning the react project and using its build output as a dependency in a project that builds a web page. This gave me an environment where I could use debugger and console handling in the react project and more directly observe the render and re-render logic through the browser's devtools Call Stack in the browser web page project.

React Deep Dive?

This is not a Deep Dive; it's simply an attempt to follow React's rendering lifecycle as observed in the setup above and organize the phases section by section. The reason for this organization is that, both when reading the earlier "React Deep Dive" series and during the study, I often got lost in the swamp of vast content and code, losing track of where in React I was currently looking… and what part of React I was learning. Dividing it into sections is meant to serve as a map (a signpost) so I don't lose my way in future studies. I'll organize the stages into the major divisions defined even on React's official site: Trigger, Render, Commit.

This is organized based on React v18.3.1, and afterward I want to compare how the Lifecycle Phase has changed in v19.


Note: organized based on React v18.3.1


react-lifecycle-phase-1.png



Trigger

Let's start from render, the beginning of the very first render in a React project. Based on the created root (ReactDOMRoot), the render call proceeds in the form below.

Functions I consider important, following the order of progression rather than depth

  • render → updateContainer → scheduleUpdateOnFiber → ensureRootIsScheduled

render: receives the App component (ReactNodeList) and begins the update

tsx
const root = createRoot(document.getElementById("root")); root.render(<App />);
tsx
ReactDOMHydrationRoot.prototype.render = ReactDOMRoot.prototype.render = function (children) { var root = this._internalRoot; ... updateContainer(children, root, null, null); };

scheduleUpdateOnFiber: starts the rendering process; as the function that schedules the update, it identifies the Fiber node that needs to change and schedules the update.

tsx
function updateContainer(element, container, parentComponent, callback) { ... var root = enqueueUpdate(current$1, update, lane); if (root !== null) { scheduleUpdateOnFiber(root, current$1, lane, eventTime); entangleTransitions(root, current$1, lane); } return lane; }

ensureRootIsScheduled: called from within scheduleUpdateOnFiber during the update-scheduling process, it does the actual scheduling by scheduling the performConcurrentWorkOnRoot function, which performs the rendering work.

tsx
function scheduleUpdateOnFiber(root, fiber, lane, eventTime) { ... ensureRootIsScheduled(root, eventTime); ... }

Through the process above, updates that require rendering are scheduled. Afterward, the scheduler schedules the performConcurrentWorkOnRoot function, which is the starting point of rendering, and the scheduled work is consumed sequentially through the workLoop loop.



Render

The updates scheduled in the Trigger stage are invoked through the postMessage of MessageChannel. The reason for using MessageChannel is that using setTimeout can incur a minimum 4ms delay on nested calls, which can affect performance; this reason is also stated in the comments within the code.

Functions I consider important, following my own sense of the order of progression rather than depth

  • performUnitOfWork → beginWork → attemptEarlyBailoutIfNoScheduledUpdate → bailoutOnAlreadyFinishedWork → reconcileChildren → completeUnitOfWork → completeWork

performUnitOfWork: called through workLoop, it is the core render function that calls beginWork to process the work on a Fiber tree node.

tsx
function performUnitOfWork(unitOfWork: Fiber): void { ... next = beginWork(current, unitOfWork, subtreeRenderLanes); ... completeUnitOfWork(unitOfWork); ... }

beginWork: compares the current Fiber node with the new (workInProgress) Fiber node to perform work, and if there are no changes, it processes the bailout logic.

tsx
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) { ... } else { ... // No pending updates or context. Bail out now. didReceiveUpdate = false; return attemptEarlyBailoutIfNoScheduledUpdate( current, workInProgress, renderLanes, ); } ...

attemptEarlyBailoutIfNoScheduledUpdate: a function that attempts a bailout to optimize away unnecessary rendering when there are no updates.

tsx
function attemptEarlyBailoutIfNoScheduledUpdate( current: Fiber, workInProgress: Fiber, renderLanes: Lanes, ) { ... return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes); }

bailoutOnAlreadyFinishedWork: a function that performs the bailout logic, which stops rendering and no longer traverses the tree when there is no pending work in the subtree.

tsx
function bailoutOnAlreadyFinishedWork( current: Fiber | null, workInProgress: Fiber, renderLanes: Lanes, ): Fiber | null {

reconcileChildren: compares the new child nodes with the previous child nodes to create a new Fiber node tree.

tsx
export function reconcileChildren( current: Fiber | null, workInProgress: Fiber, nextChildren: any, renderLanes: Lanes, ) { if (current === null) { ... workInProgress.child = mountChildFibers(... } else { ... workInProgress.child = reconcileChildFibers(... } }

completeUnitOfWork: when beginWork() did not create child nodes, it completes the work on the current Fiber node and, if necessary, creates DOM nodes or marks updates.

tsx
function completeUnitOfWork(unitOfWork: Fiber): void { ... do { ... const current = completedWork.alternate; const returnFiber = completedWork.return; if ((completedWork.flags & Incomplete) === NoFlags) { ... completeWork(current, completedWork, subtreeRenderLanes); } else { ... unwindWork(current, completedWork, subtreeRenderLanes); ... } ... workInProgress = completedWork; } while (completedWork !== null);

completeWork: depending on the Fiber node's tag, it creates or updates a DOM node and sets the necessary effect flags.

tsx
function completeWork( current: Fiber | null, workInProgress: Fiber, renderLanes: Lanes, ): Fiber | null { ... switch (workInProgress.tag) { ... }

Scheduled updates are processed by traversing the Fiber tree. In this process, during the Reconciliation stage, React compares the previous tree with the new tree and, by applying optimization techniques such as Bailout, identifies only the work that actually needs to change. The work selected this way is then collected into an Effect list to be processed later.



Commit

Once the Render Phase is complete, React enters the Commit Phase, which reflects the computed results into the actual browser environment. In this stage, based on the finalized Fiber tree, DOM manipulation, component lifecycle handling, and execution of registered Effects are performed.

Functions I consider important, following my own sense of the order of progression rather than depth

  • commitRoot → commitMutationEffectsOnFiber → flushPassiveEffects → commitPassiveMountOnFiber

commitRoot: processes all passive effects, completes all render processes, and begins the commit stage.

tsx
function commitRoot( root: FiberRoot, recoverableErrors: null | Array<CapturedValue<mixed>>, transitions: Array<Transition> | null, ) { ... commitRootImpl( root, recoverableErrors, transitions, previousUpdateLanePriority, ); ... }

commitMutationEffectsOnFiber: begins the stage that handles mutation effects such as insertion (Placement), deletion (ChildDeletion), and update (Update) (the core function that performs actual DOM manipulation based on the render tree).

tsx
function commitMutationEffectsOnFiber( finishedWork: Fiber, root: FiberRoot, lanes: Lanes, ) { ... recursivelyTraverseMutationEffects(root, finishedWork, lanes); commitReconciliationEffects(finishedWork); ... commitUpdate( instance, updatePayload, type, oldProps, newProps, finishedWork, ); ... }

flushPassiveEffects: executes the Passive effects that run asynchronously after the DOM update, and runs the cleanup functions when components unmount.

tsx
export function flushPassiveEffects(): boolean { ... return flushPassiveEffectsImpl(); ... } function flushPassiveEffectsImpl() { ... commitPassiveUnmountEffects(root.current); commitPassiveMountEffects(root, root.current, lanes, transitions); ... }

commitPassiveMountOnFiber: executes the Passive mount effects for a Fiber node and handles different kinds of Passive effects depending on the Fiber node type. It compares caches and updates only when an update is needed.

tsx
function commitPassiveMountOnFiber( finishedRoot: FiberRoot, finishedWork: Fiber, committedLanes: Lanes, committedTransitions: Array<Transition> | null, ): void { ... commitHookEffectListMount(HookPassive | HookHasEffect, finishedWork); ... }

To summarize, the Commit Phase begins after all rendering work is complete, and it first cleans up and runs the previously registered passive effects. Next, the work of actually inserting or deleting the DOM elements that need changes, and updating their attribute values, is performed. These DOM changes and side-effect handling are the final step in reflecting the changes onto the screen, forming the result visible to the user.



In Conclusion

I've organized this very briefly, dividing it into the Trigger, Render, and Commit stages, and as I said earlier, this article is a kind of 'map' I'm making so I don't lose my way while learning React—written with the sense of looking at the forest first before looking at the trees. For those reading this, I encourage you to keep in mind the possibility that this organized 'forest' may be wrong, and if you want to know more deeply about how React works, to study React Internals Deep Dive or analyze the source code yourself.

What I felt while putting this together was that, as I organized things while debugging React code, it felt like drawing a map by hand and finding my way through a maze. I hope that someday this work of wandering through mazes and drawing maps will become familiar, and that when I encounter the next maze, I'll be able to complete the map quickly and find my way out.

react-lifecycle-phase-2.png

"The creation of a thousand forests is in one acorn." — Ralph Waldo Emerson