React Hooks はとても便利で、Custom Hooks を上手く実装することで、再利用性の高いコードを、簡潔に実装することができます。
しかし、Hooksを不用意に使用してしまうと 意図しない無限ループに陥ったり、正しくStateが反映されなかったりすることがあります。
useCallback を使い無限ループを避ける
例えば以下のようなDivの大きさを取得するuseRect
というCustom Hooksについて考えてみます。
const useRect = () => {
const [rect, setRect] = useState<ClientRect | DOMRect>();
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const target = ref.current;
if (target) {
const rect = target.getBoundingClientRect();
setRect(rect);
}
}, []);
return { ref, rect };
};
このHooksは以下のようにrefをセットして使用できます。
const App: React.FC = () => {
const { ref, rect } = useRect();
return (
<div className="target" ref={ref}>
<p>Width: {rect ? `${rect.width}px` : "undefined"}</p>
<p>Height: {rect ? `${rect.height}px` : "undefined"}</p>
</div>
);
};
ここで、windowの大きさが変化した時に、divの大きさを取得し直したいと考えたとします。この場合は、handleResizeのようなhandlerを記述し、EventListenerのresizeイベントに登録することになるでしょう。
import debounce from "lodash/debounce";
export const useRect = () => {
const [rect, setRect] = useState<ClientRect | DOMRect>();
const ref = useRef<HTMLDivElement | null>(null);
const resize = () => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect();
setRect(rect);
}
};
useEffect(() => resize(), []);
const handleResize = debounce(resize, 16);
useEffect(() => {
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, [handleResize]);
return { ref, rect };
};
このHooksは一見正しく動作するように見えます。しかし、resize
関数は、描画のたびに毎回生成されてしまいます。ここで問題になるのは、resize
を呼び出すとresize
関数が新たに生成されてしまう、ということです。
例えば、うっかり、mount時にresize
を呼び出している useEffect を誤って以下のように書き換えてしまうとどうなるでしょうか?
useEffect(() => resize(), [resize]);
この場合は、resize を呼び出すと resize が生成され また useEffect が実行されてしまい、無限に処理が実行されてしまいます。
ここで、 eslint
の react-hooks/exhaustive-deps
を入れている場合は、次のような warning を出してくれます。
./src/useRect.ts
Line 8: The 'resize' function makes the dependencies of useEffect Hook (at line 18) change on every render. To fix this, wrap the 'resize' definition into its own useCallback() Hook react-hooks/exhaustive-deps
この warning に書かれている通り、この無限ループを避けるためには、useCallback
で resize
を囲むことで、描画ごとにresizeが生成されることを避ける必要があります。
const resize = useCallback(() => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect();
setRect(rect);
}
}, []);
useCallback
の代わりに useMemo
を使っても良いでしょう。
const resize = useMemo(() => {
return () => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect();
setRect(rect);
}
}
}, []);
適切にメモ化をすることで、描画ごとにresizeは生成されることはなくなり、無限ループを回避できるようになりました!
react-hooks/exhaustive-deps
を設定する
useCallback
による関数のメモ化ができていても、第二引数が正しく設定されていないと、state
の更新がうまくいかない場合があります。
例えば以下のような簡単な counter を考えます。
const App: React.FC = () => {
const [count, setCount] = useState(0);
const addCount = useCallback(() => {
setCount(count + 1);
}, []);
return (
<div className="target">
<button onClick={() => addCount()}>Add Count</button>
<p>count: {count}</p>
</div>
);
};
この例では、addCount
が 描画の最初にメモ化されていますが、メモ化されているせいでcount
の参照が古いままになっています。この例では、useCallback
には count
を第二引数に加える必要があります。
const addCount = useCallback(() => {
setCount(count + 1);
}, [count]);
react-hooks/exhaustive-deps
を lint の rule として設定していると、この場合も以下のように warning を出してくれます。
./src/App.tsx
Line 9: React Hook useCallback has a missing dependency: 'count'. Either include it or remove the dependency array. You can also do a functional update 'setCount(c => ...)' if you only need 'count' in the 'setCount' call react-hooks/exhaustive-deps
react-hooks/exhaustive-deps
は、Hooks内で参照している変数が第二引数に加えられていない場合に warning を出します。この lint を入れておくことで、古い変数を参照してしまうケースを避けることができます。
さらに、このreact-hooks/exhaustive-deps
は賢く、useState
の dispatcher や、useRef
の current など設定の必要がないものは除外してくれます。
また、 react-hooks/exhaustive-deps
を設定しておく副次的なメリットとして、仮に第二引数を意図的に変更する場合も、コメントでdisableにする必要があるということが挙げられます。
例えば、Mount時にだけ呼び出したい場合や、使用しているライブラリが毎描画ごとに変更される場合など、第二引数を意図的に変えたい時、自分たちのチームでは、なぜ react-hooks/exhaustive-deps
を disable するかのコメントを eslint-disable のコメントとともに付記しています。
このルールによって、Hooksの引数を誤って変更してしまうリストを減らすことができました。
useEffect(() => {
// ...
// apolloClient は毎描画ごとに変更されてしまうため第二引数から除外する
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [paramId]);
React の公式ドキュメントでも、react-hooks/exhaustive-deps
を設定することをお勧めしています。最新のcreate-react-app
では、デフォルトでこのルールが設定されています。
We recommend using the exhaustive-deps rule as part of our eslint-plugin-react-hooks package. It warns when dependencies are specified incorrectly and suggests a fix.
cf. Hooks API Reference – React
stop-runaway-react-effects
react-hooks/exhaustive-deps
によるルールチェックは万能ではなく、間接的に実行する関数が変化する場合をチェックできません。例えば、useRect
の例にある、 handleResize
を使用している useEffect
の処理を以下のように書き換えた場合、
useEffect(() => {
handleResize();
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, [handleResize]);
この場合も handleResize
が resize
を呼び出すため、useCallback
でメモ化されていない場合は、無限に処理が走ってしまいますが、react-hooks/exhaustive-deps
は warning を出してくれません。
そこで、自分たちのチームは stop-runaway-react-effects を使用して、 useEffect の無限ループがないか監視しています。
このライブラリは useEffect
や useLayoutEffect
の 処理を tracking して、ある一定の期間で useEffect
の実行回数が閾値を超えた場合に warning を表示してくれます。また、その warning で第二引数を表示してくれるので、どこの引数が変更されてしまっているかを確認することができます。
導入は簡単で、index に以下のコードを追加するだけです。時間当たりの閾値を設定することもできます。
import { hijackEffects } from "stop-runaway-react-effects";
if (process.env.NODE_ENV !== "production") {
hijackEffects();
// hijackEffects({ callCount: 10, timeLimit: 1000 });
}
無限ループが起きている場合には、以下のようにwarningが出ます。
まとめ
React Hooksの引数の設定・メモ化を適切に行うことで、より安全にHooksを使用することができます。具体的には、以下の3つに留意しましょう。
- 適切にメモ化を行い無限ループを避ける
- 適切なメモ化を行うために
react-hooks/exhaustive-deps
を設定する -
stop-runaway-react-effects
で無限ループを監視する(お好み)
冒頭にも書きましたが、Hooksを使用することで、再利用性の高いコードを、簡潔に実装することができます。これは、可読性やパフォーマンスの向上にも繋がります。ぜひ安全に、そして積極的にHooksを使っていきましょう!