[React] カスタムフックを作るときにこころがけていること

カスタムフックを作るときに、私が気をつけているいくつかのことを例を用いながらその理由を書いていきます。
2021.03.15

私がカスタムフックを作るときにこころがけていることがいくつかあります(こうするべきだ、と主張するものではありません)。

  1. React の Hooks にスタイルを合わせる - https://sbfl.net/blog/2020/08/21/use-react-hooks-easy/
  2. 関数は useCallback で包む - https://blog.uhy.ooo/entry/2021-02-23/usecallback-custom-hooks/
  3. テストをできるだけ書く

React の Hooks にスタイルを合わせる

上記の記事に非常に影響を受けました。とてもすばらしい記事なので先に読んでおくことを強くおすすめします。

ここでは実際にカウントアップするカウンターを作ってみます。

export const useCounter = (initialValue: number) => {
  const [count, setCount] = useState(initialValue);

  const countUp = useCallback(() => {
    setCount((prev) => prev + 1);
  }, []);

  return [count, countUp] as const;
};

戻り値にある const assertions がない場合には union type に推論されてしまうので必要になります(タプルにしても問題ありません)。

ここからさらに二倍のカウントアップもできるようにしてみます。

export const useCounter = (initialValue = 0) => {
  const [count, setCount] = useState(initialValue);

  const countUp = useCallback(() => {
    setCount((prev) => prev + 1);
  }, []);

  const doubleCountUp = useCallback(() => {
    setCount((prev) => prev * 2);
  }, []);

  return [count, { countUp, doubleCountUp }] as const;
};

あるいは { count, countUp, doubleCountUp } と戻してしまっても問題があるわけではないのですが、自分で作るのであれば上記のコードのようにしています。最初に値が入っていて、次にあるのはその値を変更できる何かだろうなという推測がしやすいと考えるからです。

次に値を変更する関数ではなく値を複数持つようなものの例として、ここでは react-useuseAsyncFn を見てみます。

const sleep = (duration: number): Promise<void> =>
  new Promise<void>((resolve) => setTimeout(resolve, duration));

const App = (): JSX.Element => {
  const [state, loadData] = useAsyncFn(async () => {
    await sleep(500);

    if (Math.random() > 0.5) {
      return "data";
    } else {
      throw new Error("Error");
    }
  }, []);

  return (
    <div>
      {state.loading ? (
        <div>ロード中</div>
      ) : state.error ? (
        <div>Error: {state.error.message}</div>
      ) : (
        <div>
          {state.value ? `Value: ${state.value}` : "Submit を押してね!"}
        </div>
      )}
      <pre>{JSON.stringify(state, null, 2)}</pre>
      <button disabled={state.loading} onClick={() => loadData()}>
        Submit
      </button>
    </div>
  );
};

state は loading, error, value を持っています。それぞれ値とそれを変更する関数が別にまとめられているので、一気に { loading, error, value, fn } と戻されるよりもわかりやすくて好きです。

また useAsyncFn のように deps をっているものは、第二引数に持たせておきましょう。react-hooks/exhaustive-deps の additionalHooks に追加することで、他の React の Hooks 同様にルールを適用できます(基本的にそういったカスタムフックを自作する機会はほとんどありませんが……)。

{
  "react-hooks/exhaustive-deps": [
    "error",
    {
      "additionalHooks": "(useAsyncFn)"
    }
  ]
}

関数は useCallback で包む

上記のとてもすばらしい記事にすべて書かれているため、あまりここで書くことはありません。

1 つだけ例をあげてみます。先ほどの useCounter を例に無限ループしてしまう useEffect を作ってみましょう。

export const useCounter = (initialValue = 0) => {
  const [count, setCount] = useState(initialValue);

  const countUp = () => {
    setCount((prev) => prev + 1);
  };

  return [count, countUp] as const;
};

const App = (): JSX.Element => {
  const [count, countUp] = useCounter();

  useEffect(() => {
    countUp();
  }, [countUp]);

  return <>{count}</>;
};

このように意図していないはずの無限ループが発生します。

このループの解決のために下記のようなコードがあったりすると、そのプロジェクトからは不吉な臭いがしてきます……。

useEffect(() => {
  countUp();
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

できるだけテストを書く

すべてのカスタムフックにテストを書かないといけないか、というとそこまでしなくてもいいかなと思っていますが、 テストがあると嬉しいので自分のためにもテストを極力書くようにしています。

CRA で作成されたプロジェクトであれば React Testing Library がはじめから含まれているはずです。

まとめ

以上が私のカスタムフックを作るときに意識していることでした。複数のカスタムフックをまとめて、いろんな値をもつオブジェクトを戻す巨大なカスタムフックのようなものはできるだけないほうが嬉しいな、と思っています。