こんにちは!Hanakla(@hanak1a_)です!
React Hooks Advent Calendar 11日目となるこの記事では、「意外と実装できないuseEffectでcomponentDidUpdateするアレ」を実装した小話をします。
TL;DR
使って、どうぞ。
import { useRef, useEffect, DependencyList } from 'react';
export const useUpdateEffect = <T extends DependencyList>(
effect: (...args: T) => void | (() => void | undefined),
watch: T,
deps: DependencyList = [],
) => {
const prevWatch = useRef<T | null>(null);
useEffect(() => {
if (prevWatch.current == null) {
prevWatch.current = watch;
return;
}
// React Hooksの内部の比較処理とObject.isを使ってない以外は同等の変更検出処理
for (const idx in watch) {
if (prevWatch.current[idx] !== watch[idx]) {
const prev = prevWatch.current;
prevWatch.current = watch;
return effect(...prev);
}
}
}, [...watch, ...deps]);
};
import { useUpdateEffect } from './useUpdateEffect';
const SomeComponent = ({ lastUpdate }: { lastUpdate: number }) => {
useUpdateEffect((prevLastUpdate) => {
// Do something when `lastUpdate` changed
}, [ lastUpdate ], [ /* なんか依存してる変数があればここに */ ]);
return null;
};
componentDidUpdateは素直にhooksに置き換えられない
みなさんHooks使ってますか? 局所的に生じるViewの重い処理をuseMemo
でキャッシュさせて高速化できると気持ちいいですね。
さて、Class ComponentからFunction Component w/ Hooksへの置き換えをしていると、実はcomponentDidUpdate
と同じような挙動をしてくれるhooksがないということに気づきました。
一番近いものでuseEffect
なんですが、これはcomponentDidUpdate
とは実行タイミングが異なります。
- componentDidUpdate
- Componentがマウントされても実行されない
- Componentが更新されたら実行される
- useEffect
- Componentがマウントされたらまず実行される
- depsに更新があれば実行される / depsが未指定なら更新ごとに実行される
さて、ざっくりした要件としてはつまり初回マウント時の処理を無視してあげるuseEffect
を作ればいいという感じですね。じゃあ作ってみましょう。
import { useRef, useEffect } from 'react';
export const useUpdateEffect = (
effect: () => (void | () => void | undefined),
) => {
// useState使うとレンダリング走りそうで嫌なので`useRef`を使う
const initial = useRef(true);
useEffect(() => {
if (initial.current) {
initial.current = false;
return;
}
return effect();
});
};
はい。一番愚直なcomponentDidUpdate
のhooks版の原型です。(たぶん実行タイミングが若干違うと思うが)じゃあもうちょっとcomponentDidUpdate
に合わせて、直前の値を使えるようにしてみましょう。
まずインターフェースとしてはこんな感じですかね
useUpdateEffect((prev) => { /* effect */ }, [ current ])
じゃあ実装します
import { useRef, useEffect, DependencyList } from 'react';
export const useUpdateEffect = (
effect: () => (void | () => void | undefined),
current?: DependencyList
) => {
// currentは必ず配列を受けるのでprevious.currentがnullかどうかだけで初回か判断してよい
// const initial = useRef(true);
const previous = useRef(null);
useEffect(() => {
if (previous.current == null) {
previous.current = current;
return;
}
return effect(...previous.current);
}, current);
};
これで多分componentDidUpdate
とほぼ同等になりました。でも実はこのコードには欠陥があります。
それはeffect
の中でクロージャ外の変数を参照すると、古い変数への参照をもちっぱなしになるという、hooksでdeps
の指定を忘れるとありがちなやつですね。なのでdeps
を追加で指定できるようにしましょう。
previous
はeffect
に渡すので、このdeps
はprevious
と混同してはいけません。実装してみます。
import { useRef, useEffect, DependencyList } from 'react';
export const useUpdateEffect = (
effect: () => (void | () => void | undefined),
current?: DependencyList
deps?: DependencyList
) => {
const previous = useRef(null);
useEffect(() => {
if (previous.current == null) {
previous.current = current;
return;
}
return effect(...previous.current);
}, [...deps, ...current]);
};
はい。これで一見いいように見えますが、depsが入った事により、depsだけの更新でもeffectが走るようになってしまいました… 本当にeffectを走らせるべきかどうかは、current
が変わったかどうかで判断しなければなりません。
またHooksとして振る舞っているので、出来るだけReact標準のHooksに近い挙動にしたいですね。なのでcurrentとpreviousの比較処理はHooksの実装コードからパクって来ましょう。
Reactのコードを読んでいくと、react-reconciler/src/ReactFiberHooks.jsにuseEffect
の定義があります。
ここからコードをたどっていくとupdateEffect → updateEffectImpl → areHooksInputsEqual と辿って、ようやくdeps
の比較処理が出てきます
for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
if (is(nextDeps[i], prevDeps[i])) {
continue;
}
return false;
}
return true;
そしてこのis
が何かというと、同ファイル内で以下のimportをされている関数です。実際のファイルはreact/packages/shared/objectIs.jsにあります。Object.is
のポリフィルのようですね。
import is from 'shared/objectIs';
とはいえ、ぱっと書き捨てのhooksにObject.is
のポリフィルをつけるのもなんだか気が重いので、雑にfor-inして!==
な要素があったら変化したということにするという方針でやります。
あとは、「関数のインターフェースにcurrent
ってあるけどwatches
の方が何の値入れてほしいのか伝わるじゃん」とか、細かい直しをすると記事の頭で出したコードになります。
import { useRef, useEffect, DependencyList } from 'react';
export const useUpdateEffect = <T extends DependencyList>(
effect: (...args: T) => void | (() => void | undefined),
watch: T,
deps: DependencyList = [],
) => {
const prevWatch = useRef<T | null>(null);
useEffect(() => {
if (prevWatch.current == null) {
prevWatch.current = watch;
return;
}
// React Hooksの内部の比較処理とObject.isを使ってない以外は同等の更新処理
for (const idx in watch) {
if (prevWatch.current[idx] !== watch[idx]) {
const prev = prevWatch.current;
prevWatch.current = watch;
return effect(...prev);
}
}
}, [...watch, ...deps]);
};
はい
いかがでしたか? 弊プロダクトでは割とcomponentDidUpdate
を使う機会が多かったので「やるかーやるしかないかー」の気持ちで作りました。(アイテムをアップロードし終わったらモーダル閉じますみたいなやつな)
御プロダクトでも使う機会があったら適当に使ってください。
明日のReact Hooks Advent Calendarは daishi さんの「react-hooks-workerの紹介」です。
(えっ待って、絶対ヤバいやつじゃん… 楽しみ…)
僕はTypeScript Advent Calendar 15日目にも型定義テクみたいな話をするので、そちらもお楽しみに。
最後まで読んでいただいてありがとうございました〜