Understanding React - Part 4. Decoding React's Handling of Function Components and Hooks

Understanding React - Part 4. Decoding React's Handling of Function Components and Hooks
October 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:

Usage of Hooks

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:

Compiled Function Component

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:

  1. The useState call retrieves the latest state and the dispatch function, allowing state updates and re-renders.
  2. The useEffect call schedules an effect that will run after the DOM is updated (in the commit stage).
  3. The component’s children (the div and its counter 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:

Errors for Invalid Hook Call

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.

Linked List Structure of Hooks

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:

Data Structure of 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 like useState) 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 by useState.
    • During the mount stage, it holds the default value passed to useState, such as useState(0).
    • During state updates, it holds the final computed value resulting from calling one or more dispatch functions, such as setCounter(prev => prev + 1).
  • 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 to memorizedState.
    • dispatch: This function appends an update to the update linked list when invoked. It corresponds to the second variable returned by useState.
  • next: Points to the next hook in the list.
Data Structure of useState

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:
Data Structure of Effects
  • tag: Represents the type of effect, determining when this effect should be flushed.
  • create: The function passed to useEffect, which executes when the dependencies change.
  • destroy: The function returned by create, which executes before the next execution of create.
  • 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 by useEffect. The head of this list is stored in the lastEffect of the updateQueue 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, so queue is null.
Data Structure of useEffect

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):

Differentiation of Hooks

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):

Exports of Hooks

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):

Implements of Hooks

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):

  1. 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.
  2. Initial Value Assignment:
    • The hook's memorizedState is initialized with the value provided as the initial state.
  3. Update Queue Creation:
    • An update queue is created to manage future state updates, and it is assigned to the hook’s queue.
  4. Dispatch Function:
    • A dispatch function is created to trigger state updates and subsequent re-renders. This function is stored in the queue.dispatch property.
  5. Return Values:
    • The hook returns an array: [hook.memorizedState, hook.queue.dispatch], which provides the initial state and the dispatch function for triggering updates.

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):

  1. 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.
  2. 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.
  3. Effect Data Structure Creation:
    • An effect data structure is created with the following properties:
      • The tag is set to a combination of PassiveEffect (indicating that it’s a useEffect hook) and HasEffect (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 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.

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:

  1. 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).
  2. For subsequent hooks, we traverse the linked list, accessing the next hook via the next property of the last current hook.
  3. 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’s next 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).
  • 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.
  • Retrieve Last Memorized State:
    • The previous state is retrieved from hook.memorizedState.
  • Process Updates:
    • All pending updates are processed sequentially, computing the final state.
  • Assign Final State:
    • The latest computed state is assigned to hook.memorizedState.
  • Return Values:
    • The hook returns [hook.memorizedState, hook.queue.dispatch], providing the latest state and the dispatch function for future updates.

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):

  1. Retrieve Current Hook:
    • Similar to useState, the corresponding current hook is retrieved from the previous fiber or the previous hook in the list.
  2. Append Hook to Linked List:
    • The updated hook is appended to the linked list.
  3. 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.

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:

  1. Iterate through the parent’s deletions list: React starts with the deletions property on the parent fiber, which holds all the fibers marked for deletion.
  2. 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.
  3. Run the destroy function for each effect: If a destroy function exists (which was returned by a previous useEffect or useLayoutEffect), React executes it to clean up side effects.
  4. Recursively traverse child fibers: After processing the effects for a fiber, React recursively traverses its child fibers and repeats the process for their effects.
  5. 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.
  6. Process sibling fibers: React moves to the next sibling and repeats the process until all siblings are processed.
  7. 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:

  1. 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.
  2. 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.
  3. Run the create function: For each effect, React executes the create function (the first argument of the useEffect hook), and the return value is saved as the destroy function for future unmounting.
  4. Process sibling fibers: After processing a fiber’s effects, React moves to its siblings and repeats the process.
  5. 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.

Related Reads