banner
IWSR

IWSR

我永远喜欢志喜屋梦子!

React Hooks: useEffect and useLayoutEffect

TLNR#

I hope this article can help readers understand the following points:

  1. When use(layout)Effect is called, an effect object is created, and this object is mounted to the fiber's updateQueue in the form of a linked list.
  2. useEffect is scheduled by the Scheduler with NormalSchedulerPriority, making it an asynchronous operation.
  3. The create of useLayoutEffect is processed synchronously during the layout phase, while its previous round's destroy is processed synchronously during the mutation phase. Therefore, calling time-consuming operations within this hook will block page updates.

Pre-reading Notes#

  • useEffect involves interaction with the Scheduler, so understanding its execution order requires some knowledge of React's scheduling behavior. In simple terms, it can be understood as follows (not a rigorous statement, just for understanding):
    • All tasks registered into the Scheduler via scheduleCallback are asynchronous tasks, and the task type is Task.
    • React has a high-priority preemption mechanism, which may interrupt the scheduling of useEffect, leading to unexpected situations.

Explanation#

We will continue to use the example code from React hooks: hooks linked list.

  function UseEffectAnduseLayoutEffect() {
    const [text, setText] = useState(0);

    useEffect(() => {
      console.log('useEffect create');
      
      return () => {
        console.log('useEffect destroy');
      }
    }, [text]);

    useLayoutEffect(() => {
      console.log('useLayoutEffect create');

      return () => {
        console.log('useLayoutEffect destroy');
      }
    }, [text]);

    return (
      <div onClick={ () => { setText(1) } }>{ text }</div>
    )
  }

Based on previous debugging experience, we know that each hook call invokes mountWorkInProgressHook to create a hook object, forming a linked list structure based on the call order, and this hook linked list will be mounted to the corresponding fiber object's memoizedState property.

Moreover, the logic of each different type of hook is not the same; here we will only analyze useEffect. But before that, let's establish a naming convention for its structure.

  use(Layout)Effect(() => { // Convention: its first parameter is create
    return () => { // Convention: the return value of the create function is destroy

    }
  }, []); // Convention: its second parameter is dependencies

Effect Linked List#

The construction of the effect linked list occurs within beginWork.

image

The hook executes different logic based on whether the component is mounting or updating. For use(Layout)Effect, it calls mount(Layout)Effect during mounting and update(Layout)Effect during updating.

Generating the Corresponding Effect Linked List During Mounting#

We first set a breakpoint on useEffect in the example, which will enter mountEffect and then mountEffectImpl.

  mountEffectImpl(
    UpdateEffect | PassiveEffect, // In React, there are many bit operations regarding permissions; here, the **or** operation often implies granting permissions.
    HookPassive,
    create,
    deps,
  );

  function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
    /**
     * The hook linked list has been analyzed, so we skip it.
    */
    const hook = mountWorkInProgressHook();
    const nextDeps = deps === undefined ? null : deps;
    /**
     * A |= B => A = A | B
    */
    currentlyRenderingFiber.flags |= fiberFlags;
    /**
     * This logic is a key point, involving the creation of the effect object and the linking of the list.
     * It is also important to note that the `memoizedState` on the corresponding hook object of `useEffect` will be mounted with the effect linked list.
     * The logic of `useLayoutEffect` is roughly the same as that of `useEffect`, but it will label the effect object differently.
    */
    hook.memoizedState = pushEffect(
      HookHasEffect | hookFlags,
      create,
      undefined,
      nextDeps,
    );
  }

pushEffect is the key to forming the effect linked list. The generated effect circular linked list will be mounted to the current component's fiber's updateQueue, and updateQueue.lastEffect will point to the latest generated effect object.

image

The structure of the effect object is as follows:

  const effect: Effect = {
    tag,
    create, // create of use(Layout)Effect
    destroy, // destroy of use(Layout)Effect
    deps, // dependencies of use(Layout)Effect
    // Circular
    next: (null: any), // points to the next effect
  };

After executing mountEffectImpl, the generated effect linked list will be mounted in two places, and useEffect will finish running at this point:

  1. The memoizedState of the corresponding hook element of use(Layout)Effect.
  2. fiber.updateQueue.

The subsequent execution of useLayoutEffect also follows the same steps, so the effect linked list will look like this:

image

In fact, this is indeed the case.

image

Thus, the logic related to generating the effect linked list during the mounting phase has concluded.

Generating the Corresponding Effect Linked List During Updating#

Keep the original breakpoint and trigger the click callback, which will enter updateEffect and then updateEffectImpl.

  updateEffectImpl(
    UpdateEffect | PassiveEffect,
    HookPassive,
    create,
    deps,
  )

  function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
    const hook = updateWorkInProgressHook(); // In the update case, currentHook will be assigned.
    const nextDeps = deps === undefined ? null : deps;
    let destroy = undefined;

    if (currentHook !== null) {
      const prevEffect = currentHook.memoizedState; // Get the effect object corresponding to currentHook.
      destroy = prevEffect.destroy; // Assign the previous destroy function to wip and call it this time.
      if (nextDeps !== null) {
        const prevDeps = prevEffect.deps;
        if (areHookInputsEqual(nextDeps, prevDeps)) { // Compare new and old deps one by one, but it's a shallow comparison.
          pushEffect(hookFlags, create, destroy, nextDeps);
          return;
        }
      }
    }

    currentlyRenderingFiber.flags |= fiberFlags;

    hook.memoizedState = pushEffect(
      HookHasEffect | hookFlags, // HookHasEffect marks the update.
      create,
      destroy,
      nextDeps,
    );
  }

Unlike mounting, the update's pushEffect will carry the previous destroy function.

About the Execution Order of Create and Destroy in useEffect#

At this point, we set a breakpoint on the create function of useEffect to observe its call stack.

image

Here we notice the function flushPassiveEffectsImpl, which is scheduled at NormalSchedulerPriority level in flushPassiveEffects, making flushPassiveEffectsImpl an asynchronous task with a low priority. However, the current example does not need to consider preemption.

By globally searching for flushPassiveEffects (the clue is the assignment of pendingPassiveHookEffectsMount), we finally locate the calling position at commitBeforeMutationEffects.

Querying asynchronous operation calls is cumbersome, but we eventually pinpoint it at commitRoot -> commitRootImpl -> commitBeforeMutationEffects. This function is the entry point for the BeforeMutation phase.

Collecting Effects#

Before executing flushPassiveEffectsImpl to clear effects, we first need to collect the effect linked list on the fiber. This operation occurs within schedulePassiveEffects. Since schedulePassiveEffects is called within commitLayoutEffects -> commitLifeCycles, it can be viewed as being called during the layout phase.

  /**
   * Here, finishedWork is the fiber object of UseEffectAnduseLayoutEffect.
  */
  function schedulePassiveEffects(finishedWork: Fiber) {
    /**
     * The corresponding effect linked list is mounted on the fiber's updateQueue.
    */
    const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
    const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
    if (lastEffect !== null) {
      // The lastEffect pointer points to the last effect object, so we can directly get the first element through next.
      const firstEffect = lastEffect.next;
      let effect = firstEffect;
      do {
        const {next, tag} = effect;
        if (
          (tag & HookPassive) !== NoHookEffect && // Filters out effect objects created by non-use(Layout)Effect.
          (tag & HookHasEffect) !== NoHookEffect // Filters out effect objects where dependencies have not changed.
        ) {
          enqueuePendingPassiveHookEffectUnmount(finishedWork, effect); // Assigns to pendingPassiveHookEffectsUnmount.
          enqueuePendingPassiveHookEffectMount(finishedWork, effect); // Assigns to pendingPassiveHookEffectsMount.
          // Currently, after executing pendingPassiveHookEffectsUnmount and pendingPassiveHookEffectsMount, the data is consistent.
        }
        effect = next;
      } while (effect !== firstEffect); // Stops after one loop of the circular linked list.
    }
  }

Executing Effects#

Directly debug flushPassiveEffectsImpl; the following code has removed redundant parts. This function involves the logic for executing the create and destroy on the effect objects.

function flushPassiveEffectsImpl() {
  ...
  /**
   * Execute the destroy function of the effect.
   * During mounting, since the third parameter in pushEffect is undefined, the destroy property on the effect object is empty. Therefore, destroy will not be executed.
   * However, during updating, the destroy function returned by create will be passed in, so it will be executed. This will be mentioned later.
  */
  const unmountEffects = pendingPassiveHookEffectsUnmount;
  pendingPassiveHookEffectsUnmount = [];
  for (let i = 0; i < unmountEffects.length; i += 2) {
    const effect = ((unmountEffects[i]: any): HookEffect);
    const fiber = ((unmountEffects[i + 1]: any): Fiber);
    const destroy = effect.destroy;
    effect.destroy = undefined;

    if (typeof destroy === 'function') {
      if (
        enableProfilerTimer &&
        enableProfilerCommitHooks &&
        fiber.mode & ProfileMode
      ) {
        try {
          startPassiveEffectTimer();
          destroy();
        } finally {
          recordPassiveEffectDuration(fiber);
        }
      } else {
        destroy();
      }
    }
  }
  /**
   * Execute the create function of the effect.
  */
  const mountEffects = pendingPassiveHookEffectsMount;
  pendingPassiveHookEffectsMount = [];
  for (let i = 0; i < mountEffects.length; i += 2) {
    const effect = ((mountEffects[i]: any): HookEffect);
    const fiber = ((mountEffects[i + 1]: any): Fiber);

    const create = effect.create;
    if (
      enableProfilerTimer &&
      enableProfilerCommitHooks &&
      fiber.mode & ProfileMode
    ) {
      try {
        startPassiveEffectTimer();
        /**
         * The return value of create is destroy. If this effect object is collected again due to an update,
         * it will be pointed to during the next flushPassiveEffectsImpl.
        */
        effect.destroy = create(); 
      } finally {
        recordPassiveEffectDuration(fiber);
      }
    } else {
      effect.destroy = create();
    }

  }
  ...

  return true;
}

Conclusion#

  1. useEffect is an asynchronous operation, and the operation to clear side effects (flushPassiveEffectsImpl) will be registered as a Task.
  2. During the layout phase, effects will be collected into the execution array, but scheduling occurs during the before Mutation scheduling.
  3. The scheduling priority of the operation to clear side effects (flushPassiveEffectsImpl) is not high, so it may be preempted by higher-priority tasks during scheduling.
  4. During mounting, only the create function will be executed; during updating, the currentHook's destroy will be executed first, followed by the newHook's create.

About the Timing of useLayoutEffect Execution#

useLayoutEffect, aside from its triggering timing, behaves similarly to useEffect.

At this point, we set a breakpoint on the create function of useLayoutEffect to observe its call stack.

image

At this point, we set a breakpoint on commitHookEffectListMount and find that all operations are synchronous. (commitLayoutEffects is the entry point for layout)

function commitHookEffectListMount(tag: number, finishedWork: Fiber) {
  const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
  const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
  if (lastEffect !== null) {
    const firstEffect = lastEffect.next;
    let effect = firstEffect;
    do {
      // The passed tag is HookLayout | HookHasEffect, so we can filter out useLayoutEffect's effects.
      if ((effect.tag & tag) === tag) { 
        // Mount
        const create = effect.create;
        effect.destroy = create(); 
      }
      effect = effect.next;
    } while (effect !== firstEffect); // Loop once.
  }
}

If we set a breakpoint on the destroy function of useLayoutEffect.

image

At this point, we set a breakpoint on commitHookEffectListUnmount and find that all operations are synchronous. (commitMutationEffects is the entry point for mutation)

function commitHookEffectListUnmount(tag: number, finishedWork: Fiber) {
  const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
  const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
  if (lastEffect !== null) {
    const firstEffect = lastEffect.next;
    let effect = firstEffect;
    do {
      if ((effect.tag & tag) === tag) {
        // Unmount
        const destroy = effect.destroy;
        effect.destroy = undefined;
        if (destroy !== undefined) {
          destroy();
        }
      }
      effect = effect.next;
    } while (effect !== firstEffect);
  }
}

Conclusion#

  1. useLayoutEffect is called synchronously, with its destroy called during the mutation phase and its create called during the layout phase.
Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.