192
133

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

安全に React Hooks を使用する

Posted at

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 が実行されてしまい、無限に処理が実行されてしまいます。

ここで、 eslintreact-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 に書かれている通り、この無限ループを避けるためには、useCallbackresize を囲むことで、描画ごとに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]);

この場合も handleResizeresize を呼び出すため、useCallback でメモ化されていない場合は、無限に処理が走ってしまいますが、react-hooks/exhaustive-deps は warning を出してくれません。

そこで、自分たちのチームは stop-runaway-react-effects を使用して、 useEffect の無限ループがないか監視しています。

このライブラリは useEffectuseLayoutEffect の 処理を 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が出ます。

Screen Shot 2019-05-26 at 20.12.09.png

まとめ

React Hooksの引数の設定・メモ化を適切に行うことで、より安全にHooksを使用することができます。具体的には、以下の3つに留意しましょう。

  • 適切にメモ化を行い無限ループを避ける
  • 適切なメモ化を行うためにreact-hooks/exhaustive-depsを設定する
  • stop-runaway-react-effects で無限ループを監視する(お好み)

冒頭にも書きましたが、Hooksを使用することで、再利用性の高いコードを、簡潔に実装することができます。これは、可読性やパフォーマンスの向上にも繋がります。ぜひ安全に、そして積極的にHooksを使っていきましょう!

192
133
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
192
133

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?