React Source Code

    7. Functional components - construction details and updates (Part 2)

    Published
    November 20, 2022
    Reading Time
    4 min read
    Author
    Felix
    Access
    Public

    In the previous article, we discussed the building and updating process of functional components. This article will delve into how React hooks work.

    First, let us understand the classification of hooks:

    1. State Hook: such as useState and useReducer
    2. Side effect Hook (Effect Hook): such as useEffect and useLayoutEffect
    3. Ref Hook: such as useRef

    This article will talk about the implementation of state Hook, useState and useReducer.

    2. Implementation of useState

    useState is one of the most commonly used Hooks in React. Its implementation is divided into two main functions: mountState (for initialization) and updateState (for updates). Let us first look at the implementation of mountState.

    2.1 mountState

    function mountState<S>(
      initialState: (() => S) | S,
    ): [S, Dispatch<BasicStateAction<S>>] {
      const hook = mountWorkInProgressHook();
    
      if (typeof initialState === 'function') {
        initialState = initialState();
      }
    
      hook.memoizedState = hook.baseState = initialState;
    
      const queue = (hook.queue = {
        pending: null,
        dispatch: null,
        lastRenderedReducer: basicStateReducer,
        lastRenderedState: (initialState: any),
      });
    
      const dispatch: Dispatch<
        BasicStateAction<S>,
      > = (queue.dispatch = (dispatchAction.bind(
        null,
        currentlyRenderingFiber,
        queue,
      ): any));
    
      return [hook.memoizedState, dispatch];
    }

    Let's analyze this function line by line:

    1. const hook = mountWorkInProgressHook(); This line of code creates a new hook object and adds it to the current fiber's hooks list. This is the key mechanism for React to manage multiple hooks.

    2. if (typeof initialState === 'function') {
        initialState = initialState()
      }

      This is an optimization that allows the user to pass in a function to calculate the initial state. This is useful for complex or expensive initial state calculations, as it will only be performed once when the component is mounted.

    3. hook.memoizedState = hook.baseState = initialState; The hook's memoizedState and baseState are initialized here. memoizedState stores the current state, while baseState is used to calculate the new state during the update process.

    4. const queue = (hook.queue = {
        pending: null,
        dispatch: null,
        lastRenderedReducer: basicStateReducer,
        lastRenderedState: (initialState: any),
      });

      This creates an update queue. pending is used to store pending updates, dispatch is the update function, lastRenderedReducer is the reducer used in the last rendering (for useState, this is a basic state update function), lastRenderedState is the state of the last rendering.

    5. const dispatch: Dispatch<
        BasicStateAction<S>,
      > = (queue.dispatch = (dispatchAction.bind(
        null,
        currentlyRenderingFiber,
        queue,
      ): any));

      This creates the dispatch function. It is actually a bound version of the dispatchAction function, which pre-binds the current fiber and update queue. This is an optimization that avoids having to rebind these parameters every time dispatch is called.

    6. return [hook.memoizedState, dispatch]; Finally, the current state and dispatch function are returned. This is the array we get when using useState.

    2.2 dispatchAction

    Next, let's look at the dispatchAction function, which is the core of status updates:

    function dispatchAction<S, A>(
      fiber: fiber,
      queue: UpdateQueue<S, A>,
      action: A,
    ) {
      const eventTime = requestEventTime();
      const lane = requestUpdateLane(fiber);
    
      const update: Update<S, A> = {
        lane,
        action,
        eagerReducer: null,
        eagerState: null,
        next: (null: any),
      };
    
      const pending = queue.pending;
      if (pending === null) {
        update.next = update;
      } else {
        update.next = pending.next;
        pending.next = update;
      }
      queue.pending = update;
    
      const alternate = fiber.alternate;
      if (
        fiber === currentlyRenderingFiber ||
        (alternate !== null && alternate === currentlyRenderingFiber)
      ) {
        didScheduleRenderPhaseUpdateDuringThisPass = didScheduleRenderPhaseUpdate = true;
      } else {
        if (
          fiber.lanes === NoLanes &&
          (alternate === null || alternate.lanes === NoLanes)
        ) {
          const lastRenderedReducer = queue.lastRenderedReducer;
          if (lastRenderedReducer !== null) {
            let prevDispatcher;
            try {
              const currentState: S = (queue.lastRenderedState: any);
              const eagerState = lastRenderedReducer(currentState, action);
              update.eagerReducer = lastRenderedReducer;
              update.eagerState = eagerState;
              if (is(eagerState, currentState)) {
                return;
              }
            } catch (error) {
              // Suppress the error. It will throw again in the render phase.
            }
          }
        }
        scheduleUpdateOnFiber(fiber, lane, eventTime);
      }
    }

    Let's analyze this function step by step:

    1. const eventTime = requestEventTime()
      const lane = requestUpdateLane(fiber)

      These two lines of code get the current event time and update priority (lane). React uses the lane model to manage the priority of updates, which is an important optimization mechanism.

    2. const update: Update<S, A> = {
        lane,
        action,
        eagerReducer: null,
        eagerState: null,
        next: (null: any),
      };

      This creates a new update object. lane represents the priority of the update, action is the action of state update, eagerReducer and eagerState are used for optimization, and next is used to link to the next update.

    3. const pending = queue.pending
      if (pending === null) {
        update.next = update
      } else {
        update.next = pending.next
        pending.next = update
      }
      queue.pending = update

      This code adds new updates to the update queue. Note that the circular linked list structure is used here, which allows React to handle multiple updates efficiently.

    4. if (fiber === currentlyRenderingFiber || (alternate !== null && alternate === currentlyRenderingFiber)) {
        didScheduleRenderPhaseUpdateDuringThisPass = didScheduleRenderPhaseUpdate = true
      } else {
        // ... (optimization logic)
      }

      This code checks if an update is scheduled during the render phase. If so, it sets a flag so that React can start a new render as soon as the current render completes.

    5. if (fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes)) {
        // ... (optimization logic)
      }

      This is an important optimization. If there are no pending updates to the current fiber, React will try to calculate the new state immediately.

    6. const lastRenderedReducer = queue.lastRenderedReducer
      if (lastRenderedReducer !== null) {
        // ... (optimization logic)
      }

      Here React tries to use the reducer from the last render to calculate the new state. This is an optimization that avoids unnecessary re-rendering.

    7. const currentState: S = (queue.lastRenderedState: any);
      const eagerState = lastRenderedReducer(currentState, action);
      update.eagerReducer = lastRenderedReducer;
      update.eagerState = eagerState;
      if (is(eagerState, currentState)) {
        return;
      }

      This code calculates the new state (eagerState). If the new state is the same as the current state, the function returns directly, avoiding unnecessary updates.

    8. scheduleUpdateOnFiber(fiber, lane, eventTime); If an update is needed, this line of code will schedule an update.

    2.3 updateState

    When the component is updated, updateState is called. This function actually calls updateReducer:

    function updateState<S>(
      initialState: (() => S) | S,
    ): [S, Dispatch<BasicStateAction<S>>] {
      return updateReducer(basicStateReducer, (initialState: any));
    }

    updateReducer is a complex function that handles the actual updating of state. Let's look at its key parts:

    function updateReducer<S, I, A>(
      reducer: (S, A) => S,
      initialArg: I,
      init?: I => S,
    ): [S, Dispatch<A>] {
      const hook = updateWorkInProgressHook();
      const queue = hook.queue;
    
      queue.lastRenderedReducer = reducer;
    
      // ... (process baseQueue and pendingQueue)
    
      if (baseQueue !== null) {
        const first = baseQueue.next;
        let newState = current.baseState;
    
        let newBaseState = null;
        let newBaseQueueFirst = null;
        let newBaseQueueLast = null;
        let update = first;
        do {
          const updateLane = update.lane;
          if (!isSubsetOfLanes(renderLanes, updateLane)) {
            // The priority is not enough, skip this update
            const clone: Update<S, A> = {
              lane: updateLane,
              action: update.action,
              eagerReducer: update.eagerReducer,
              eagerState: update.eagerState,
              next: (null: any),
            };
            if (newBaseQueueLast === null) {
              newBaseQueueFirst = newBaseQueueLast = clone;
              newBaseState = newState;
            } else {
              newBaseQueueLast = newBaseQueueLast.next = clone;
            }
            currentlyRenderingFiber.lanes = mergeLanes(
              currentlyRenderingFiber.lanes,
              updateLane,
            );
          } else {
            // Priority is sufficient, apply this update
            if (newBaseQueueLast !== null) {
              const clone: Update<S, A> = {
                lane: NoLane,
                action: update.action,
                eagerReducer: update.eagerReducer,
                eagerState: update.eagerState,
                next: (null: any),
              };
              newBaseQueueLast = newBaseQueueLast.next = clone;
            }
    
            if (update.eagerReducer === reducer) {
              // If there is a cached calculation result, use it directly
              newState = ((update.eagerState: any): S);
            } else {
              // Otherwise, call the reducer to calculate the new state
              const action = update.action;
              newState = reducer(newState, action);
            }
          }
          update = update.next;
        } while (update !== null && update !== first);
    
        // ... (update hook status)
      }
    
      const dispatch: Dispatch<A> = (queue.dispatch: any);
      return [hook.memoizedState, dispatch];
    }

    The core of this function is a loop that iterates through all pending updates. Let's analyze it step by step:

    1. const hook = updateWorkInProgressHook(); This line of code gets the currently working hook. React uses a linked list structure to manage multiple hooks in a component, which allows the hooks to maintain a stable order during rendering.

    2. queue.lastRenderedReducer = reducer; Update the reducer used by the last rendering stored in the queue. This is for subsequent optimization.

    3. The next big loop is the core of the entire function, which traverses all pending updates.

    4. if (!isSubsetOfLanes(renderLanes, updateLane)) { ... } This condition checks whether the priority of the current update is high enough. If the priority is not high enough, the update will be skipped and added to a new queue, waiting for the next rendering.

    5. if (update.eagerReducer === reducer) { ... } This is an important optimization. If the current update already has a precomputed result (eagerState), and the reducer used has not changed, then the precomputed result will be used directly to avoid repeated calculations.

    6. newState = reducer(newState, action); If there is no precomputed result, the reducer is called to calculate the new state.

    This function demonstrates several key optimizations in React's state update process:

    • Priority scheduling: Through the lane model, React can skip low-priority updates and prioritize high-priority updates.
    • Batch updates: Multiple updates are placed in a queue and processed together, improving efficiency.
    • Precomputation: Through eagerReducer and eagerState, React can avoid double calculation in certain situations.

    3. Implementation of useReducer

    The implementation of useReducer is very similar to useState. The main difference is that useReducer allows users to provide custom reducer functions. Let’s look at the implementation of mountReducer:

    function mountReducer<S, I, A>(
      reducer: (S, A) => S,
      initialArg: I,
      init?: I => S,
    ): [S, Dispatch<A>] {
      const hook = mountWorkInProgressHook();
      let initialState;
      if (init !== undefined) {
        initialState = init(initialArg);
      } else {
        initialState = ((initialArg: any): S);
      }
      hook.memoizedState = hook.baseState = initialState;
      const queue = (hook.queue = {
        pending: null,
        dispatch: null,
        lastRenderedReducer: reducer,
        lastRenderedState: (initialState: any),
      });
      const dispatch: Dispatch<A> = (queue.dispatch = (dispatchAction.bind(
        null,
        currentlyRenderingFiber,
        queue,
      ): any));
      return [hook.memoizedState, dispatch];
    }

    This function is very similar to mountState, the main differences are:

    1. It accepts a reducer function as a parameter.
    2. It allows the state to be initialized through the init function, which provides a more flexible initialization method.

    The update process of useReducer (updateReducer) is exactly the same as useState, which we have analyzed in detail before.

    4. Think deeply

    1. Essence of Hooks: Hooks are essentially a method of state management. They allow functional components to have their own state without needing to be converted to class components. This greatly simplifies writing React components.

    2. Linked list structure: React uses linked lists to manage hooks, which allows hooks to maintain a stable order between multiple renderings. This is why hooks cannot be used in conditional statements.

    3. Batch update and priority: React’s update mechanism is very sophisticated. It can batch multiple updates and decide which updates should be processed first based on priority. This greatly improves the performance and responsiveness of the application.

    4. Lazy initialization: Both useState and useReducer support lazy initialization, that is, a function can be passed in to calculate the initial state. This is useful for complex initial state calculations to avoid having to recalculate them on every render.

    5. Closure Trap: The implementation of Hooks relies on JavaScript’s closure mechanism. This can lead to subtle bugs, such as using old state in asynchronous operations. Understanding this is very important for using hooks correctly.

    Comments

    Join the conversation

    0 comments
    Sign in to comment

    No comments yet. Be the first to add one.