はじめに
useCallback
はReactのライフサイクル間でキャッシュされた関数を返すReactのhooksです。
主に依存配列等で比較するReactの機能で、関数が同一であることを保証するために利用されます。
const Parent = () => {
const something = useCallback(() => {
...
}, []);
return (
<div>
{/* ... */}
<Child something={something} />
</div>
);
};
const Child = memo((something) => {
// ...
}, []);
上記の例でsomething
をuseCallback
で囲っていなければ、Parent
のレンダリングのたびに新しいsomething
関数が作成されるため、Child
のレンダリングもParent
に合わせて実行されます。
somthing
をuseCallback
で囲うことで、Parent
のレンダリング間で同一のsomething
関数が利用されるため、Child
のレンダリングは実行されません。
const Example = () => {
const something = useCallback(() => {
// ...
}, []);
useEffect(() => {
const timeoutId = setTimeout(() => {
something();
}, 1000);
return () => {
clearTimeout(timeoutId);
};
}, [something]);
...
};
上記の例は初回のレンダリング後にsomething
を使ったuseEffect
の第一引数の処理を実行します。
something
がuseCallback
に囲まれていなければExample
のレンダリングのたびにsomething
が作り直されるので、useEffect
は依存配列が変化したとみなして、レンダリングのたびに第一引数の処理が実行されます。
さて、私はuseCallback
について1つ勘違いをしていました。異なるライフサイクル間で同じ関数を返してくれるので、関数は毎回作られないと思っていたのです。
しかし、Reactのドキュメントには
useCallback は関数の作成を防ぐわけではないことに注意してください。あなたは常に関数を作成しています(それは問題ありません!)。しかし、何も変わらない場合、React はそれを無視し、キャッシュされた関数を返します。
useCallback
はただキャッシュを返すだけで、常に関数を作成すると書かれています。
それもそのはずで、useCallback
の第一引数に対象の関数をそのまま渡しているので、useCallback
を呼び出すタイミングで関数は作られているのです。
この件は解決しましたが、この他にもuseCallback
に知らない動作が存在するか気になりました。
この記事ではその胸のつっかえをとるべく、useCallback
の実装について追っていきます。
useCallbackの実装を探す
useCallback
はreact/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__
の部分は無視すると、ReactSharedInternals
のH
が返されていました。
function resolveDispatcher() {
const dispatcher = ReactSharedInternals.H;
return ((dispatcher: any): Dispatcher);
}
ReactSharedInternals.H
を定義する部分を探すと、react-reconciler
のrenderWithHooks
で実装されていました。
対象の箇所だけ掘り出すと以下のようになっています。
ReactSharedInternals.H =
current === null || current.memoizedState === null
? HooksDispatcherOnMount
: HooksDispatcherOnUpdate;
初期に実行するHooksDispatcherOnMount
と、それ以降に実行するHooksDispatcherOnUpdate
で分かれているようですね。
HooksDispatcherOnMountはここに記述されており、useCallback
はmountCallback
が呼ばれているようでした。
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,
};
同じファイル内にmountCallback
とupdateCallback
が書かれています。
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の実装を読む
まず、目につくのがmountWorkInProgressHook
とupdateWorkInProgressHook
です。話が逸れすぎるのでそれぞれの詳細までは追わないことにします。
mountWorkInProgressHook
はhooksが呼び出された最初のタイミングに使われます。hooksの情報を格納する場所を作っており、その場所はhooksが呼ばれた順番に作られます。
updateWorkInProgressHook
はmountWorkInProgressHook
で作成された場所から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のドキュメントのuseMemo
とuseCallback
の関係によると
すでに useMemo に詳しい場合、useCallback を次のように考えると役立つかもしれません。
// Simplified implementation (inside React)
function useCallback(fn, dependencies) {
return useMemo(() => fn, dependencies);
}
と書かれていたので、useCallback
はuseMemo
の実装に依存しているかもと考えていましたが、依存していないことを確認できたのでよかったです。
おわりに
useCallback
で常にキャッシュされる仕様を知らなかったので、他にも知らない仕様があるか確認するためにuseCallback
の実装を確認しました。
目新しいものはなかったですが、具体的な実装方法を知れてよりReactの深淵に近づけた気がしました。
皆さんも気になるhooksがあれば確認してみてはいかがでしょうか。