2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

useCallbackは関数を作成しないらしいので実装を見て他に知らない仕様がないか確認する

Posted at

はじめに

useCallbackはReactのライフサイクル間でキャッシュされた関数を返すReactのhooksです。

主に依存配列等で比較するReactの機能で、関数が同一であることを保証するために利用されます。

親の変更を子に伝えない
const Parent = () => {
  const something = useCallback(() => {
    ...
  }, []);
  
  return (
    <div>
      {/* ... */}
      <Child something={something} />
    </div>
  );
};

const Child = memo((something) => {
  // ...
}, []);

上記の例でsomethinguseCallbackで囲っていなければ、Parentのレンダリングのたびに新しいsomething関数が作成されるため、ChildのレンダリングもParentに合わせて実行されます。
somthinguseCallbackで囲うことで、Parentのレンダリング間で同一のsomething関数が利用されるため、Childのレンダリングは実行されません。

useEffectの頻繁な発火を防ぐ
const Example = () => {
  const something = useCallback(() => {
    // ...
  }, []);

  useEffect(() => {
    const timeoutId = setTimeout(() => {
      something();
    }, 1000);

    return () => {
      clearTimeout(timeoutId);
    };
  }, [something]);

  ...
};

上記の例は初回のレンダリング後にsomethingを使ったuseEffectの第一引数の処理を実行します。
somethinguseCallbackに囲まれていなければExampleのレンダリングのたびにsomethingが作り直されるので、useEffectは依存配列が変化したとみなして、レンダリングのたびに第一引数の処理が実行されます。

さて、私はuseCallbackについて1つ勘違いをしていました。異なるライフサイクル間で同じ関数を返してくれるので、関数は毎回作られないと思っていたのです。

しかし、Reactのドキュメントには

useCallback は関数の作成を防ぐわけではないことに注意してください。あなたは常に関数を作成しています(それは問題ありません!)。しかし、何も変わらない場合、React はそれを無視し、キャッシュされた関数を返します。

useCallbackはただキャッシュを返すだけで、常に関数を作成すると書かれています。
それもそのはずで、useCallbackの第一引数に対象の関数をそのまま渡しているので、useCallbackを呼び出すタイミングで関数は作られているのです。

この件は解決しましたが、この他にもuseCallbackに知らない動作が存在するか気になりました。
この記事ではその胸のつっかえをとるべく、useCallbackの実装について追っていきます。

useCallbackの実装を探す

useCallbackreact/packages/react/src/ReactHooks.jsから世界が広がります。

export function useCallback<T>(
  callback: T,
  deps: Array<mixed> | void | null,
): T {
  const dispatcher = resolveDispatcher();
  return dispatcher.useCallback(callback, deps);
}

resolveDispatcherを呼び出し、そこに登録されたuseCallbackを呼び出しているようです。

同じファイルのL26にその実装があります。
__DEV__の部分は無視すると、ReactSharedInternalsHが返されていました。

function resolveDispatcher() {
  const dispatcher = ReactSharedInternals.H;
  return ((dispatcher: any): Dispatcher);
}

ReactSharedInternals.Hを定義する部分を探すと、react-reconcilerrenderWithHooksで実装されていました。

対象の箇所だけ掘り出すと以下のようになっています。

ReactSharedInternals.H =
      current === null || current.memoizedState === null
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate;

初期に実行するHooksDispatcherOnMountと、それ以降に実行するHooksDispatcherOnUpdateで分かれているようですね。

HooksDispatcherOnMountはここに記述されており、useCallbackmountCallbackが呼ばれているようでした。

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,
  useHostTransitionStatus: useHostTransitionStatus,
  useFormState: mountActionState,
  useActionState: mountActionState,
  useOptimistic: mountOptimistic,
  useMemoCache,
  useCacheRefresh: mountRefresh,
};

HooksDispatcherOnUpdateもすぐ下にあります。

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,
  useHostTransitionStatus: useHostTransitionStatus,
  useFormState: updateActionState,
  useActionState: updateActionState,
  useOptimistic: updateOptimistic,
  useMemoCache,
  useCacheRefresh: updateRefresh,
};

同じファイル内にmountCallbackupdateCallbackが書かれています。

function mountCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  if (nextDeps !== null) {
    const prevDeps: Array<mixed> | null = prevState[1];
    if (areHookInputsEqual(nextDeps, prevDeps)) {
      return prevState[0];
    }
  }
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

ここが、useCallbackの本実装なようですね。

useCallbackの実装を読む

まず、目につくのがmountWorkInProgressHookupdateWorkInProgressHookです。話が逸れすぎるのでそれぞれの詳細までは追わないことにします。
mountWorkInProgressHookはhooksが呼び出された最初のタイミングに使われます。hooksの情報を格納する場所を作っており、その場所はhooksが呼ばれた順番に作られます。
updateWorkInProgressHookmountWorkInProgressHookで作成された場所からhooksの順番に従って情報を取り出します。(コンポーネントのトップレベルで呼び出さなければいけないのはこれが理由です)

なんとなくの役割が分かったところで、mountCallbackの実装を確認します。

function mountCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

mountWorkInProgressHookで情報を配置できる箇所を確保して、そこにコールバック関数と依存配列を保存して、コールバック関数を返しています。
mountCallbackでは情報を保存して、元の関数を返すだけのようですね。

次に、updateCallbackです。

function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  if (nextDeps !== null) {
    const prevDeps: Array<mixed> | null = prevState[1];
    if (areHookInputsEqual(nextDeps, prevDeps)) {
      return prevState[0];
    }
  }
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

updateWorkInProgressHookで前回の情報を取り出しています。
そして、依存配列がない場合を除いて、areHookInputsEqualで前回の依存配列の状態と今回の依存配列の状態で比較しています。
areHookInputsEqualは依存配列でループを回して状態の比較をする関数です(状態を比較する方法はisで実装されています)。

function areHookInputsEqual(
  nextDeps: Array<mixed>,
  prevDeps: Array<mixed> | null,
): boolean {
  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    // $FlowFixMe[incompatible-use] found when upgrading Flow
    if (is(nextDeps[i], prevDeps[i])) {
      continue;
    }
    return false;
  }
  return true;
}
// react/packages/shared/objectIs.js
function is(x: any, y: any) {
  return (
    (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y)
  );
}

依存配列の状態が同じと判断された場合は前回から登録されていた関数、つまりキャッシュされた関数を返します。
そして、依存配列の状態に変化があればupdateWorkInProgressHookで取り出した情報を更新して、最新の関数を返します。

以上がuseCallbackの実装でした。特に想像と違うとこはありませんでした。

ReactのドキュメントのuseMemouseCallbackの関係によると

すでに useMemo に詳しい場合、useCallback を次のように考えると役立つかもしれません。

// Simplified implementation (inside React)
function useCallback(fn, dependencies) {
  return useMemo(() => fn, dependencies);
}

と書かれていたので、useCallbackuseMemoの実装に依存しているかもと考えていましたが、依存していないことを確認できたのでよかったです。

おわりに

useCallbackで常にキャッシュされる仕様を知らなかったので、他にも知らない仕様があるか確認するためにuseCallbackの実装を確認しました。
目新しいものはなかったですが、具体的な実装方法を知れてよりReactの深淵に近づけた気がしました。
皆さんも気になるhooksがあれば確認してみてはいかがでしょうか。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?