Open28

Reactの内部実装を見ていくよ(useStateフック&ステート更新からのトリガーフェーズ)

calloc134calloc134

resolveDispatcher.js
https://github.com/facebook/react/blob/main/packages/react/src/ReactHooks.js#L30


function resolveDispatcher() {
  const dispatcher = ReactSharedInternals.H;
  if (__DEV__) {
    if (dispatcher === null) {
      console.error(
        'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' +
          ' one of the following reasons:\n' +
          '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' +
          '2. You might be breaking the Rules of Hooks\n' +
          '3. You might have more than one copy of React in the same app\n' +
          'See https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem.',
      );
    }
  }
  // Will result in a null access error if accessed outside render phase. We
  // intentionally don't throw our own error because this is in a hot path.
  // Also helps ensure this is inlined.
  return ((dispatcher: any): Dispatcher);
}

ReactSharedInternals.H;はどこからやってくるのじゃ〜〜〜〜、となる

calloc134calloc134

なんとその答えは別パッケージにある
react-reconcilerを参照
renderWithHooks関数内
https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberHooks.js


export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes,
): any {
  renderLanes = nextRenderLanes;
  currentlyRenderingFiber = workInProgress;
(省略)
  if (__DEV__) {
    if (current !== null && current.memoizedState !== null) {
      ReactSharedInternals.H = HooksDispatcherOnUpdateInDEV;
    } else if (hookTypesDev !== null) {
      // This dispatcher handles an edge case where a component is updating,
      // but no stateful hooks have been used.
      // We want to match the production code behavior (which will use HooksDispatcherOnMount),
      // but with the extra DEV validation to ensure hooks ordering hasn't changed.
      // This dispatcher does that.
      ReactSharedInternals.H = HooksDispatcherOnMountWithHookTypesInDEV;
    } else {
      ReactSharedInternals.H = HooksDispatcherOnMountInDEV;
    }
  } else {
    ReactSharedInternals.H =
      current === null || current.memoizedState === null
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate;
  }
...

ざっくり
currentがnull もしくはcurrentがあってもmemorizedStateがnullなら
初回のマウント時だと判断してHooksDispatcherOnMountをいれる
そうじゃないなら二回目以降の更新時だと判断してHooksDispatcherOnUpdateをいれる
という感じ?

https://zenn.dev/ktmouk/articles/68fefedb5fcbdc#なぜfiberを使うのか
currentというのはFiberツリーのcurrentのことだろうか?違うかも

calloc134calloc134

HooksDispatcherOnMountとは
https://github.com/facebook/react/blob/1839e1437f652819682f2c7970687ac19e551534/packages/react-reconciler/src/ReactFiberHooks.js#L3806


const HooksDispatcherOnMount: Dispatcher = {
  readContext,

  use,
  useCallback: mountCallback,
  useContext: readContext,
  useEffect: mountEffect,
  useImperativeHandle: mountImperativeHandle,
  useLayoutEffect: mountLayoutEffect,
  useInsertionEffect: mountInsertionEffect,
  useMemo: mountMemo,
  useReducer: mountReducer,
  useRef: mountRef,
  useState: mountState,
  useDebugValue: mountDebugValue,
  useDeferredValue: mountDeferredValue,
  useTransition: mountTransition,
  useSyncExternalStore: mountSyncExternalStore,
  useId: mountId,
};

const HooksDispatcherOnUpdate: Dispatcher = {
  readContext,

  use,
  useCallback: updateCallback,
  useContext: readContext,
  useEffect: updateEffect,
  useImperativeHandle: updateImperativeHandle,
  useInsertionEffect: updateInsertionEffect,
  useLayoutEffect: updateLayoutEffect,
  useMemo: updateMemo,
  useReducer: updateReducer,
  useRef: updateRef,
  useState: updateState,
  useDebugValue: updateDebugValue,
  useDeferredValue: updateDeferredValue,
  useTransition: updateTransition,
  useSyncExternalStore: updateSyncExternalStore,
  useId: updateId,
};

え、なにこれは

calloc134calloc134

とりあえず、useStateの処理を追いたい場合にはmountState関数かupdateState関数を追跡すればいいらしい

  • 初回のマウント時の場合
    HooksDispatcherOnMountはmountState関数
  • 二回目の更新時の場合
    HooksDispatcherOnUpdateはupdateState関数
calloc134calloc134

mountState/updateStateはどこから?
https://github.com/facebook/react/blob/1839e1437f652819682f2c7970687ac19e551534/packages/react-reconciler/src/ReactFiberHooks.js#L1926C1-L1944C2

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountStateImpl(initialState);
  const queue = hook.queue;
  const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any);
  queue.dispatch = dispatch;
  return [hook.memoizedState, dispatch];
}

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

いよいよここにたどり着いた

calloc134calloc134

まずmountStateの感想

  • mountStateImplでフックのオブジェクトを取得している?
  • dispatchSetStateで現在レンダリング中のFiberオブジェクトとフックの持つキューをバインドして、更新用関数を作成している?
  • ステートの閲覧する方はフック内部のmemorizedStateというプロパティを参照している
  • 配列に入れて返却!いつもよく見るステートの戻り値や!
calloc134calloc134

mountStateImplとは何をしているのか?
https://github.com/facebook/react/blob/1839e1437f652819682f2c7970687ac19e551534/packages/react-reconciler/src/ReactFiberHooks.js#L1898

function mountStateImpl<S>(initialState: (() => S) | S): Hook {
  const hook = mountWorkInProgressHook();
  if (typeof initialState === 'function') {
    const initialStateInitializer = initialState;
    // $FlowFixMe[incompatible-use]: Flow doesn't like mixed types
    initialState = initialStateInitializer();
    if (shouldDoubleInvokeUserFnsInHooksDEV) {
      setIsStrictModeForDevtools(true);
      try {
        // $FlowFixMe[incompatible-use]: Flow doesn't like mixed types
        initialStateInitializer();
      } finally {
        setIsStrictModeForDevtools(false);
      }
    }
  }
  hook.memoizedState = hook.baseState = initialState;
  const queue: UpdateQueue<S, BasicStateAction<S>> = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  };
  hook.queue = queue;
  return hook;
}

calloc134calloc134

とりあえずまず呼び出されているmountWorkInProgressHookとは?

https://github.com/facebook/react/blob/1839e1437f652819682f2c7970687ac19e551534/packages/react-reconciler/src/ReactFiberHooks.js#L989


function mountWorkInProgressHook(): Hook {
  const hook: Hook = {
    memoizedState: null,

    baseState: null,
    baseQueue: null,
    queue: null,

    next: null,
  };

  if (workInProgressHook === null) {
    // This is the first hook in the list
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // Append to the end of the list
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}
calloc134calloc134

やってること

現在のフックが初呼び出しなら

workInProgressHookとcurrentlyRenderingFiber.memoizedStateに現在のフックを代入

現在のフックが二個目以降のフックなら

workInProgressHookとworkInProgressHook.nextに現在のフックを代入←???

workInProgressHookを返却

calloc134calloc134

ここでヒント!

https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberHooks.js#L195

export type Hook = {
  memoizedState: any,
  baseState: any,
  baseQueue: Update<any, any> | null,
  queue: any,
  next: Hook | null,
};

フックの型はこうなっているっぽい

hook.memoizedState: メモリに保持されているローカルな状態。
hook.baseState: hook.baseQueue内のすべてのアップデートオブジェクトがマージされた後の状態。
hook.baseQueue: 現在のレンダリング優先度よりも高いものだけを含む、アップデートオブジェクトの循環的なチェーン。
hook.queue: 優先度の高いすべてのアップデートオブジェクトを含む、アップデートオブジェクトの循環的なチェーン。
hook.next: 次のポインタ、チェーンの次のフックを指します。

つまり、フックオブジェクトは連結リストになっているっぽい
なお、nextはnull許容なので、次のフックオブジェクトがない場合も当然ある

calloc134calloc134

コード再掲

function mountStateImpl<S>(initialState: (() => S) | S): Hook {
  const hook = mountWorkInProgressHook();
  if (typeof initialState === 'function') {
    const initialStateInitializer = initialState;
    // $FlowFixMe[incompatible-use]: Flow doesn't like mixed types
    initialState = initialStateInitializer();
    if (shouldDoubleInvokeUserFnsInHooksDEV) {
      setIsStrictModeForDevtools(true);
      try {
        // $FlowFixMe[incompatible-use]: Flow doesn't like mixed types
        initialStateInitializer();
      } finally {
        setIsStrictModeForDevtools(false);
      }
    }
  }
  hook.memoizedState = hook.baseState = initialState;
  const queue: UpdateQueue<S, BasicStateAction<S>> = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  };
  hook.queue = queue;
  return hook;
}

calloc134calloc134

感想

  • initialStateはuseStateで最初に渡す値な気がする
    • コールバック関数が渡されてたらそれを実行する?
useState(() => うんちゃら) //みたいな?
  • initialStateを、memorizedStateとmemorizedStateに代入
  • hook.queueに空っぽのキューを代入して初期化
  • 完成したフックを返却
calloc134calloc134

mountStateImplの調査を終わらせて、mountStateの調査に戻る

コード再掲

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountStateImpl(initialState);
  const queue = hook.queue;
  const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any);
  queue.dispatch = dispatch;
  return [hook.memoizedState, dispatch];
}

今度はdispatchSetStateが気になるので見てみる

calloc134calloc134

dispatchSetState関数
https://github.com/facebook/react/blob/1839e1437f652819682f2c7970687ac19e551534/packages/react-reconciler/src/ReactFiberHooks.js#L3534

function dispatchSetState<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
): void {
  if (__DEV__) {
    if (typeof arguments[3] === 'function') {
      console.error(
        "State updates from the useState() and useReducer() Hooks don't support the " +
          'second callback argument. To execute a side effect after ' +
          'rendering, declare it in the component body with useEffect().',
      );
    }
  }

  const lane = requestUpdateLane(fiber);
  const didScheduleUpdate = dispatchSetStateInternal(
    fiber,
    queue,
    action,
    lane,
  );
  if (didScheduleUpdate) {
    startUpdateTimerByLane(lane);
  }
  markUpdateInDevTools(fiber, lane, action);
}

実質的な処理はdispatchSetStateInternalに全部書いてありそう・・・

calloc134calloc134

dispatchSetStateInternal関数
https://github.com/facebook/react/blob/1839e1437f652819682f2c7970687ac19e551534/packages/react-reconciler/src/ReactFiberHooks.js#L3534


function dispatchSetStateInternal<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
  lane: Lane,
): boolean {
  const update: Update<S, A> = {
    lane,
    revertLane: NoLane,
    action,
    hasEagerState: false,
    eagerState: null,
    next: (null: any),
  };

  if (isRenderPhaseUpdate(fiber)) {
    enqueueRenderPhaseUpdate(queue, update);
  } else {
    const alternate = fiber.alternate;
    if (
      fiber.lanes === NoLanes &&
      (alternate === null || alternate.lanes === NoLanes)
    ) {
      // The queue is currently empty, which means we can eagerly compute the
      // next state before entering the render phase. If the new state is the
      // same as the current state, we may be able to bail out entirely.
      const lastRenderedReducer = queue.lastRenderedReducer;
      if (lastRenderedReducer !== null) {
        let prevDispatcher = null;
        if (__DEV__) {
          prevDispatcher = ReactSharedInternals.H;
          ReactSharedInternals.H = InvalidNestedHooksDispatcherOnUpdateInDEV;
        }
        try {
          const currentState: S = (queue.lastRenderedState: any);
          const eagerState = lastRenderedReducer(currentState, action);
          // Stash the eagerly computed state, and the reducer used to compute
          // it, on the update object. If the reducer hasn't changed by the
          // time we enter the render phase, then the eager state can be used
          // without calling the reducer again.
          update.hasEagerState = true;
          update.eagerState = eagerState;
          if (is(eagerState, currentState)) {
            // Fast path. We can bail out without scheduling React to re-render.
            // It's still possible that we'll need to rebase this update later,
            // if the component re-renders for a different reason and by that
            // time the reducer has changed.
            // TODO: Do we still need to entangle transitions in this case?
            enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
            return false;
          }
        } catch (error) {
          // Suppress the error. It will throw again in the render phase.
        } finally {
          if (__DEV__) {
            ReactSharedInternals.H = prevDispatcher;
          }
        }
      }
    }

    const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
    if (root !== null) {
      scheduleUpdateOnFiber(root, fiber, lane);
      entangleTransitionUpdate(root, queue, lane);
      return true;
    }
  }
  return false;
}
calloc134calloc134
  if (isRenderPhaseUpdate(fiber)) {
    enqueueRenderPhaseUpdate(queue, update);
  }
  • isRenderPhaseUpdate関数でレンダリング中かどうかを判定
  • レンダリング中の場合は更新キューに追加
    enqueueRenderPhaseUpdate(queue, update);
  • レンダリング中でないならenqueueConcurrentHookUpdateAndEagerlyBailout関数呼び出し?
    これは何をしてるのかわからなかったが、今回は無視!
    (balioutという処理を動かしているっぽい。ハック)

// Fast path. We can bail out without scheduling React to re-render.
// It's still possible that we'll need to rebase this update later,
// if the component re-renders for a different reason and by that
// time the reducer has changed.
// TODO: Do we still need to entangle transitions in this case?

  • どっちみちenqueueConcurrentHookUpdate関数は実行される
  • rootがnullでないならscheduleUpdateOnFiberとentangleTransitionUpdateを実行
    const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
    if (root !== null) {
      scheduleUpdateOnFiber(root, fiber, lane);
      entangleTransitionUpdate(root, queue, lane);
      return true;
    }
calloc134calloc134

enqueueRenderPhaseUpdate関数を軽く確認
https://github.com/facebook/react/blob/1839e1437f652819682f2c7970687ac19e551534/packages/react-reconciler/src/ReactFiberHooks.js#L3702


function enqueueRenderPhaseUpdate<S, A>(
  queue: UpdateQueue<S, A>,
  update: Update<S, A>,
): void {
  // This is a render phase update. Stash it in a lazily-created map of
  // queue -> linked list of updates. After this render pass, we'll restart
  // and apply the stashed updates on top of the work-in-progress hook.
  didScheduleRenderPhaseUpdateDuringThisPass = didScheduleRenderPhaseUpdate =
    true;
  const pending = queue.pending;
  if (pending === null) {
    // This is the first update. Create a circular list.
    update.next = update;
  } else {
    update.next = pending.next;
    pending.next = update;
  }
  queue.pending = update;
}
  • キューは基本的に循環リストにさせる
  • pending = キューの一番新入りupdateオブジェクト キューなので一番最後尾
  • pendingがnullなら (空っぽ)
    • nextにはupdateを指定
  • そうじゃないなら
    • 最後尾に並ぶ
    • updateオブジェクトのnextをpendingのnextとして指定
      • 循環リストなのでpending.nextは最前列のupdateオブジェクト
    • pendingのnextに今のupdateオブジェクトの参照をいれる
    • キューのpendingを今のupdateオブジェクトであると更新
calloc134calloc134

enqueueConcurrentHookUpdate関数
https://github.com/facebook/react/blob/65a56d0e99261481c721334a3ec4561d173594cd/packages/react-reconciler/src/ReactFiberConcurrentUpdates.js#L114

export function enqueueConcurrentHookUpdate<S, A>(
  fiber: Fiber,
  queue: HookQueue<S, A>,
  update: HookUpdate<S, A>,
  lane: Lane,
): FiberRoot | null {
  const concurrentQueue: ConcurrentQueue = (queue: any);
  const concurrentUpdate: ConcurrentUpdate = (update: any);
  enqueueUpdate(fiber, concurrentQueue, concurrentUpdate, lane);
  return getRootForUpdatedFiber(fiber);
}

enqueueUpdate関数のラッパー?
enqueueUpdate関数の詳細は以下を参照
https://zenn.dev/link/comments/4ce1fc9c30d4d5

ここでフックのupdateキューをFiberの持つupdateキューに追加しているということだろうか?

calloc134calloc134

enqueueRenderPhaseUpdate関数は満足
mountState関数も満足
一通り見てきた感じがする

ここから気になること

  • キューに入れられたupdateオブジェクトはどのように拾われるの?
  • 初回マウント時しか見れてないけど更新時はどうなるんだろう

一気に解決していく

calloc134calloc134

scheduleUpdateOnFiber関数で再レンダリングをスケジュールしているっぽい
ここからはreact scheduler側のコードになるっぽい

calloc134calloc134

ここ以降はまた別のスクラップで確認することに

calloc134calloc134

また、renderWithHooks関数はどこから呼び出されるのか
→これはレンダーフェーズ編で解説する!