Understanding React - Part 4. Decoding React's Handling of Function Components and HooksOctober 7# Tech# Front-End# React
Introduction
In our previous discussions, we explored how React traverses the fiber tree during the begin work stage to process state updates and reconcile child fiber nodes. Initially, function components were designed as pure components, meaning they did not maintain internal state but were solely responsible for rendering the UI. However, with the introduction of hooks in React 16, function components became stateful, transforming them into the primary development paradigm in React. Understanding the mechanisms of hooks is essential for grasping the design patterns that underlie React.
In this post, we will dive into the internals of two classic hooks: useState
and useEffect
, to better understand the design patterns of hooks.
The Architecture of Hooks
At its core, a function component is an executable function that returns its children in the form of JSX, a UI description. During the begin work phase, each time a function component is encountered, React calls the component function and processes its return value to continue the rendering process.
Given that a function component is invoked repeatedly, and unlike class components, doesn’t retain state between renders, how does React inject state into function components? This is where hooks come into play.
Warm-Up: Hook Usage Example
Consider this simple function component using useState
and useEffect
:
When the <App />
component mounts, the counter
is initially set to 0, which is displayed on the screen and logged to the console. Clicking the div
increments the counter
, triggering a re-render, resulting in the updated counter
value (1) being painted and logged.
Now, let’s explore what happens behind the scenes.
First, the App
component is compiled:
During the render phase, as React encounters the <App />
function component, a corresponding fiber is created or updated, and the App
function is executed to compute the latest state and return the children. Several key steps occur during this execution:
- The
useState
call retrieves the latest state and the dispatch function, allowing state updates and re-renders. - The
useEffect
call schedules an effect that will run after the DOM is updated (in the commit stage). - The component’s children (the
div
and itscounter
value) are returned, ready for the next phase of begin work.
Once the render stage completes, all states are updated, and effects are collected. In the commit stage, these updates are applied to the host environment (e.g., the DOM), and the effects are executed (such as the console.log
in useEffect
).
The Design Pattern of Hooks
Hooks are functions provided by React that enable stateful logic within function components. But how does React distinguish between different components and ensure each hook call is tied to the correct component instance? The answer lies in the fiber architecture.
During the execution of a function component in the begin work phase, the component’s fiber is injected into the runtime context. This context ensures that all subsequent hook calls are correctly associated with the current component. This is why calling hooks outside of a function component results in an error from React:
When used within a function component, each hook call creates a data structure and appends it to a linked list. The head of this list is stored in the memorizedState
property of the component’s fiber. These hooks maintain state (useState
), side effects (useEffect
), or other behavior. Each hook in the list contains necessary information, such as the current state value or effect cleanup function. The linked list structure ensures that hooks are always called in the same order during renders, allowing React to efficiently preserve state and apply updates.
The internal logic of each hook varies depending on the type of hook. In this post, we’ll focus on the most common hooks: useState
and useEffect
. But first, let’s examine the hook data structure.
The Hook Data Structure
Here’s a simplified view of the data structure used for hooks:
This structure primarily consists of:
memorizedState
: Stores the hook’s current state. Its value differs depending on the type of hook.queue
: Contains a linked list of updates (for hooks likeuseState
) that will be processed during rendering.next
: Points to the next hook in the linked list, regardless of its type.
Now let’s break this down for useState
and useEffect
.
useState
useState
is the cornerstone of React’s reactivity, responsible for managing state.
memorizedState
: Serves as the repository for reactive data, specifically the first variable in the array returned byuseState
.- During the mount stage, it holds the default value passed to
useState
, such asuseState(0)
. - During state updates, it holds the final computed value resulting from calling one or more dispatch functions, such as
setCounter(prev => prev + 1)
.
- During the mount stage, it holds the default value passed to
queue
: Plays a crucial role in updating reactive values. It contains two properties:pending
: This stores the head of the circular linked list containing all updates. When React computes the latest states of a function component during the begin work phase, these updates are processed to compute the final state, which is then assigned tomemorizedState
.dispatch
: This function appends an update to the update linked list when invoked. It corresponds to the second variable returned byuseState
.
next
: Points to the next hook in the list.
useEffect
useEffect
is designed to perform side effects in a function component. Unlike useState
, which stores reactive data, useEffect
maintains a reference to side effects (typically functions) that should be triggered at specific times.
memorizedState
: References the related effect data structure, defined as follows:
tag
: Represents the type of effect, determining when this effect should be flushed.create
: The function passed touseEffect
, which executes when the dependencies change.destroy
: The function returned bycreate
, which executes before the next execution ofcreate
.deps
: The dependencies for the effect, dictating whether the effect should be flushed.next
: Points to the subsequent effect in a circular linked list consisting of all effects created byuseEffect
. The head of this list is stored in thelastEffect
of theupdateQueue
for the fiber of the function component. By maintaining all effects in a separate list, we improve traversal and flushing efficiency for a function component's effects.
queue
: There is no need to store updates, soqueue
is null.
Creation of Hooks
After understanding the hook data structures, let’s explore how hooks are created. Fundamentally, the core function of hook creation is to instantiate the hook's data structure during each hook call and link them to the respective fiber. The process of creating these hook data structures varies slightly between the mount and update stages. For example, when you invoke useState
during the mount and update stages, two distinct implementations are used. This differentiation is due to the internal mechanics of React, as reflected in the source code (GitHub reference: ReactFiberHooks.ts):
All hook functions are dispatched through ReactSharedInternals.H
, which serves as the source of all exported hooks when you import them in your code (e.g., import { useState } from 'react'
). Here’s the relevant source code for how this dispatching works (GitHub reference: ReactFiberHooks.ts):
The decision to assign either HooksDispatcherOnMount
or HooksDispatcherOnUpdate
to ReactSharedInternals.H
is determined by the presence of a current
fiber. If current
is null (which happens during the initial mount), HooksDispatcherOnMount
is used. Otherwise, HooksDispatcherOnUpdate
is employed.
These dispatcher objects hold the implementations for the respective lifecycle phases (GitHub reference: ReactFiberHooks.ts):
Now, let's explore the specific implementations of hooks for each phase.
The Mount Phase
In the mount phase, React initializes the hook's state or effect for the first time.
useState
When useState
is called during the mount phase, the following steps occur (GitHub reference: ReactFiberHooks.ts):
- Hook Data Structure Creation:
- A new hook data structure is created.
- This hook is appended to the linked list of hooks:
- If it’s the first hook, it is stored in the fiber's
memorizedState
property. - Otherwise, it’s appended to the end of the linked list.
- If it’s the first hook, it is stored in the fiber's
- Initial Value Assignment:
- The hook's
memorizedState
is initialized with the value provided as the initial state.
- The hook's
- Update Queue Creation:
- An update queue is created to manage future state updates, and it is assigned to the hook’s
queue
.
- An update queue is created to manage future state updates, and it is assigned to the hook’s
- Dispatch Function:
- A dispatch function is created to trigger state updates and subsequent re-renders. This function is stored in the
queue.dispatch
property.
- A dispatch function is created to trigger state updates and subsequent re-renders. This function is stored in the
- Return Values:
- The hook returns an array:
[hook.memorizedState, hook.queue.dispatch]
, which provides the initial state and the dispatch function for triggering updates.
- The hook returns an array:
At this stage, the initial state is ready for rendering, and React has prepared the necessary structures to handle future updates.
useEffect
The useEffect
hook, when invoked during the mount phase, performs the following tasks (GitHub reference: ReactFiberHooks.ts):
- Hook Data Structure Creation:
- A new hook data structure is created and appended to the linked list:
- If it’s the first hook, it is stored in the fiber’s
memorizedState
property. - Otherwise, it’s appended to the end of the linked list.
- If it’s the first hook, it is stored in the fiber’s
- A new hook data structure is created and appended to the linked list:
- Flags Assignment:
- Flags are recorded in the fiber’s
flags
property to indicate that this fiber contains effects that need to be processed during the commit phase.
- Flags are recorded in the fiber’s
- Effect Data Structure Creation:
- An effect data structure is created with the following properties:
- The
tag
is set to a combination ofPassiveEffect
(indicating that it’s auseEffect
hook) andHasEffect
(signifying that the effect should be triggered at the commit phase). - The
create
function is defined as the effect function to be executed. - Since this is the mount phase, no
destroy
function exists yet. - The
deps
(dependencies) are set to the provided dependency array.
- The
- The effect is appended to a circular linked list of effects:
- If it’s the first effect, an update queue is created, and the effect is appended to it, forming a circular structure where the effect points to itself.
- Otherwise, the effect is appended to the end of the list.
- An effect data structure is created with the following properties:
At this point, React has recorded the effect and tagged the fiber to ensure the effect is executed later during the commit phase.
The Update Phase
During the update phase, which is triggered when a re-render occurs, the handling of hooks becomes focused on maintaining the previous state and ensuring updates are applied correctly. A new work-in-progress (WIP) hook linked list must be created based on the previous one, so that the latest states and effects are properly recorded.
When updating a hook, we must first locate the corresponding hook from the current linked list. Here's the step-by-step process:
- If the hook is the first one in the WIP list, we retrieve the current hook from the
memorizedState
property of the alternate fiber (i.e., the previous render's fiber). - For subsequent hooks, we traverse the linked list, accessing the next hook via the
next
property of the last current hook. - If we cannot find the corresponding current hook (i.e., if the WIP hook list length does not match the current list), it indicates an error where hooks are called conditionally or out of order.
This is critical because React enforces a rule that hooks must always be called in the same order across renders. Violating this rule will cause state inconsistencies and trigger errors. This rule ensures that React can properly match hooks between renders and track their state.
useState
During the update phase, React performs these actions for useState
(GitHub reference: ReactFiberHooks.ts):
- Retrieve Current Hook:
- The corresponding current hook is retrieved from the previous fiber’s
memorizedState
(if this is the first hook) or from the previous hook’snext
property. - If the current hook is null, this indicates a mismatch in the hook order, which is a critical error in React (hooks must always be called in the same order).
- The corresponding current hook is retrieved from the previous fiber’s
- Append Hook to Linked List:
- The updated hook is appended to the linked list.
- Retrieve Pending Updates:
- The hook's
queue.pendingUpdate
property stores the head of the circular linked list containing all updates queued by the dispatch function.
- The hook's
- Retrieve Last Memorized State:
- The previous state is retrieved from
hook.memorizedState
.
- The previous state is retrieved from
- Process Updates:
- All pending updates are processed sequentially, computing the final state.
- Assign Final State:
- The latest computed state is assigned to
hook.memorizedState
.
- The latest computed state is assigned to
- Return Values:
- The hook returns
[hook.memorizedState, hook.queue.dispatch]
, providing the latest state and the dispatch function for future updates.
- The hook returns
At this stage, React has processed all state updates and is ready to proceed with the next render.
useEffect
When useEffect
is called during the update phase, the following steps occur (GitHub reference: ReactFiberHooks.ts):
- Retrieve Current Hook:
- Similar to
useState
, the corresponding current hook is retrieved from the previous fiber or the previous hook in the list.
- Similar to
- Append Hook to Linked List:
- The updated hook is appended to the linked list.
- Compare Dependencies:
- The new dependencies are compared to the old ones using the
Object.is
method:- If the dependencies are the same, React skips this effect by pushing it into the list without marking it with the
HasEffect
flag. The effect remains part of the internal structure but will not be executed during the commit phase. - If the dependencies have changed, React marks the effect with the
HasEffect
flag, indicating that it should be executed during the commit phase.
- If the dependencies are the same, React skips this effect by pushing it into the list without marking it with the
- The new dependencies are compared to the old ones using the
At this point, React has determined which effects need to be executed or skipped based on the dependency changes.
Flush of useEffect
When React processes useEffect
during the render phase, it schedules the effect but does not execute it immediately. Instead, React flushes useEffect
once the components have been rendered and the DOM updates are committed. This means useEffect
runs after the visual content is displayed, preventing it from blocking the render process and ensuring smooth user interactions.
Scheduling useEffect
at the Commit Phase
At the commit stage, React checks the root fiber for specific flags that were set during the creation of hooks. These flags bubble up from child components to the root fiber. If any of these flags are detected, React schedules the flushing of useEffect
by pushing the callback into a macro task queue using the schedule
package from React. This ensures that the effects run asynchronously, preventing them from interfering with the main rendering flow.
(GitHub code reference: ReactFiberWorkLoop.ts)
It’s worth noting that useLayoutEffect
is handled differently. Unlike useEffect
, which runs asynchronously, useLayoutEffect
is flushed synchronously after DOM mutations but before the browser paints. This synchronous execution blocks the rendering process temporarily, ensuring that any DOM-related side effects are handled before the user sees the changes. While this behavior is essential for certain use cases (e.g., measuring DOM elements), it can negatively impact performance if overused.
Processing useEffect
: Unmount Effects
GitHub code reference: ReactFiberCommitWork.ts
Let's break down the execution of useEffect
effects, starting with unmount effects, which are committed before mount effects.
React follows a specific order for committing effects. It begins by processing the parent’s deletion effects, followed by effects on child components, and finally, effects on the parent component itself.
When children are reconciled during the begin work phase, React tags fibers that need to be deleted with a deletion flag. These deleted fibers are collected in the deletions
property of the parent fiber, forming a list of nodes to be removed.
During the unmount process, React follows these steps:
- Iterate through the parent’s
deletions
list: React starts with thedeletions
property on the parent fiber, which holds all the fibers marked for deletion. - Access the effects of each fiber: For each fiber marked for deletion, React retrieves the
updateQueue.lastEffect
, which is the head of a linked list of effects. - Run the
destroy
function for each effect: If adestroy
function exists (which was returned by a previoususeEffect
oruseLayoutEffect
), React executes it to clean up side effects. - Recursively traverse child fibers: After processing the effects for a fiber, React recursively traverses its child fibers and repeats the process for their effects.
- Detach the fiber from the parent: Once all the effects for a fiber and its children have been processed, React detaches the fiber from the parent and clears its data.
- Process sibling fibers: React moves to the next sibling and repeats the process until all siblings are processed.
- Return to the parent: Finally, React returns to the parent fiber and processes its deletion effects.
This approach ensures that React cleans up child components first, preventing potential memory leaks and ensuring that effects are unmounted in the correct order.
Processing useEffect
: Mount Effects
After unmount effects are processed, React commits the mount effects:
- Traverse from root to leaf fibers: React begins traversing the fiber tree from the root fiber down to the leaf nodes, following the same order used for unmount effects.
- Access the effects of each fiber: For each fiber, React retrieves the
updateQueue.lastEffect
, which is the head of the linked list of effects for that fiber. - Run the
create
function: For each effect, React executes thecreate
function (the first argument of theuseEffect
hook), and the return value is saved as thedestroy
function for future unmounting. - Process sibling fibers: After processing a fiber’s effects, React moves to its siblings and repeats the process.
- Return to the parent: Once all child fibers are processed, React returns to the parent fiber and commits its effects.
Why Deletions Are Processed First
Processing deletion effects before mount effects ensures that the component tree is cleaned up in a predictable and efficient way. By removing obsolete components first, React avoids running unnecessary effects or updates on components that are no longer part of the tree. This step also ensures that resources tied to deleted components are released promptly, preventing memory leaks.
In the End
Function components have transformed in React, evolving from pure components to stateful ones through hooks. Hooks, structured as linked lists in the fiber architecture, enable state management and side effects in function components.
The useState
hook manages state by holding the current value and a queue for updates, initializing during mounting and processing updates thereafter. Similarly, useEffect
registers side effects and checks dependencies, executing only when necessary. This design enhances rendering efficiency by separating state management from the rendering logic, allowing for smoother updates and improved performance.
By understanding these concepts, developers can effectively leverage hooks in their React applications, creating more efficient and responsive UIs. As we continue to explore React internals, these insights will deepen our understanding of the framework's capabilities and performance optimizations.