Reactのアーキテクチャを知る useState編1

こんにちは、noyanです。React内部実装アドベントカレンダーの1日目です。

この記事ではReactのuseStateフックの内部実装を読み解いていきます。

概要

Reactを使っている人のほとんどがuseStateを使ったことがあるでしょう。一方で内部実装を知っているひとはかなり限られるように思います。

このシリーズでは、useStateのコードリーディングを行います。useState HookのAPIは便利で直感的ですが、その実装は複雑で難解です。useStateを我々が呼び出す時、裏でどのような処理が行われているのでしょうか。useState編では関連する内部処理を概覧し、メンタルモデルを構築することを試みます。

「でも役にたたないでしょ?」。たしかにuseStateを実際に使う役には立たないかもしれません。ですが、Reactの内部実装のなかで比較的閉じたスコープのAPIを読解できるようになれば、あなたは次から自力でコードを読む手がかりを得るでしょう。Server ComponentsやConcurrent Renderingについて他人の寸評で耳学問するより、コードを知って判断するほうがずっとクールだと思いませんか。

コードリーディング

useState編1ではconst [count, setCount] = useState(0)というありふれたコードを題材に、useStateがマウント時にどう処理されているか理解します。

useState: エントリーポイント

さて、useStateを呼んでみましょう。

const [count, setCount] = useState(0)

useState関数はこのように定義されています。

 export function useState<S>(
   initialState: (() => S) | S,
 ): [S, Dispatch<BasicStateAction<S>>] {
   const dispatcher = resolveDispatcher();
   return dispatcher.useState(initialState);
 }

dispatcherのuseStateメソッドを使用しているようです。

 function resolveDispatcher() {
   const dispatcher = ReactCurrentDispatcher.current;
   return ((dispatcher: any): Dispatcher);
 }

resolveDispatcherはほとんどなにもしていません。ReactCurrentDispatcherに直接さわらないことで、実装修正の影響スコープを狭めるという見方が正しそうです。

ここまで見て、useState(0)ReactCurrentDispatcher.useState(0)を返していることがわかりました。

renderWithHooks

次にReactCurrentDispatcherに代入を行う部分を見る必要があります。その場所とはrenderWithHooksです。実際のコードは多少長いですが、概略的には次の処理をしています。

export function renderWithHooks(current, workInProgress, Component, props, secondArg, nextRanderLane):any {
    // Dispatcherを付与
    ReactCurrentDispatcher.current =
        current === null || current.memoizedState === null
          ? HooksDispatcherOnMount
          : HooksDispatcherOnUpdate;
         
    // 更新がある限り計算を続ける
    let children
    do {children = Component(props)} while (hasUpdate)
    
    return children
 } 

Dispatcherを割り当てた後、コンポーネントレンダリングしています。たとえば次のGreetコンポーネントがあるとします。

const Greet= ({name}) => (<div>Hello, {name}!</div>)
// or
// const Greet = ({ name  })=>React.createElement("div", null, "Hello, ", name, "!")

renderWithHooksGreetコンポーネントが与えられるとGreet({name})を計算して、その計算結果をchildrenとして返しているわけです。実際にレンダリングの計算をしている部分という感じがします。

さて、上の4行目でReactCurrentDIspatcherHooksDispatcherOnMountHooksDispatcherOnUpdateを割り当てています。

代入されている変数の接尾語には、それぞれonMountとonUpdateとあります。我々はいまuseStateのマウント時の挙動に興味があるので、onMountを見ることにします。更新時の挙動を調べるときには、またここから始めることになるでしょう。

HooksDispatcherOnMount

このDispatcherはオブジェクトで、キーに各Hooks、値にmount時に呼ぶべき関数が定義されています。

これまでuseStateReactCurrentDispatcher.useState(initialState)を返す関数だとわかっているので、useState(0)HooksDispatcherOnMount.useState(0)、つまりmountState(0)を実行していることになります。

const HooksDispatcherOnMount: Dispatcher = {
  readContext,

  // ...
  useState: mountState,
  useReducer: mountReducer,
  useRef: mountRef,
  // ...
};

mountState

まず戻り値だけ見てみましょう。mountState[hook.memoizedState, dispatch]を返します。

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  // ...
  return [hook.memoizedState, dispatch];
}

つまり、useStatemountStateの実行結果を戻り値としているわけです。マウント時には実質的に次のコードが等価であることがわかりました。

const [count, setCount] = useState(0);
const [count, setCount] = mountState(0)

おわり

「さて、コードを詳しくみていこう!」といきたいのですが、これには少し道具立てが必要です。また明日トライしてみることにしましょう。気になる人のためにmountStateのコードを貼っておきます。

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountWorkInProgressHook();
  if (typeof initialState === 'function') {
    // $FlowFixMe: Flow doesn't like mixed types
    initialState = initialState();
  }
  hook.memoizedState = hook.baseState = initialState;
  const queue: UpdateQueue<S, BasicStateAction<S>> = {
    pending: null,
    interleaved: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  };
  hook.queue = queue;
  const dispatch: Dispatch<
    BasicStateAction<S>,
  > = (queue.dispatch = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any));
  return [hook.memoizedState, dispatch];
}