【React】カスタムフックでstatefulなコンポーネントを整理する

こんにちは。最近は仕事で React を書き始めました。数年前に React を触った時は class component をせっせと書いた覚えがあるのですが、最近は functional component と react hooks を組み合わせて書くこともできるようです。react hooks の概要は公式ドキュメント等を読んでもらうことにして、本記事では、自作のカスタムフックで stateful なコンポーネントを整理する一例を紹介し、それによって享受できるメリットを少し深掘りしたいと思います。

整理する前のコード

まずは僕が最初にお試しで書いた TypeScript のコードをお見せします。インターネットに転がっている例をつぎはぎして、見よう見まねで書いた投稿フォームです。コード中の useQuery は、apollo client のフックで、与えられた GraphQL クエリを POST して結果を取得してくれるものです。より現実的な課題を共有するために追加しています。

const MyForm: React.FC = () => {
  // ログイン中の自分のアカウントを取得する
  const { data, loading, error }: GetMeQueryResult = useQuery(getMeQuery);
  const [body, setBody] = React.useState('');
  const [message, setMessage] = React.useState<string | undefined>(undefined);

  const handleChange = 
    React.useCallback<React.ChangeEventHandler<HTMLInputElement>>(
      e => {
        if (e.target.name === 'body') setBody(e.target.value);
      },
      [setBody]
    );

  const handleSubmit = React.useCallback<React.FormEventHandler<HTMLFormElement>>(
    async e => {
      e.preventDefault();
      if (window.confirm('本当に投稿しますか?')) {
        setMessage('投稿中です。');
        // body を REST API 経由で投稿する
        await post(body);
        setMessage('投稿が完了しました。');
      }
    },
    [body, setMessage]
  );

  if (loading) return <p>ロード中…</p>;
  if (error) return <p>投稿できません。</p>;

  return (
    <form onSubmit={handleSubmit}>
      {message === undefined ? <div>{message}</div> : null}
      <span>{`ユーザー名「${data!.me.name}」として投稿します。`}</span>
      <input name={'body'} value={body} onChange={handleChange} />
      <button>投稿</button>
    </form>
  );
};

動かすので精一杯といった様子ですが、初めてにしては頑張った方ではないでしょうか。早速、このコードの何が問題かを考えましょう。ひとことで言えば「ロジックとビューが分離していない」というバッドプラクティスのお手本を踏んでいるわけですが、そこをもう少し丁寧に言葉にします。

ロジックの再利用性が低い

handleSubmit の内部を見てください。post の前後で setMessage をして、ユーザーが処理の様子を把握できるようにしています。例えば、この投稿機能を別の場所でも使いたい、しかも、ボタンで投稿するのではなく Enter を押して投稿するインラインのフォームにしたい、と言われたらどうでしょうか。今のままでは handleSubmit をコピペしてお茶を濁すしかないように見えます。状態に対する操作がコンポーネントと密結合しているため、コードの再利用性が下がっています。

テストがしづらい

このコンポーネントをテストすることを考えてみましょう。プロダクトの品質を高めるべく意気揚々と取りかかりますが、次々と面倒ごとが襲ってきます。

まず、MyFormレンダリングしないことにはテストのしようがないので、useQuery のモックは必須でしょう。フロントエンドのテストで実際の GraphQL API へ依存はしたくありません。幸い apollo client は useQuery 等を mock するための仕組みを用意してくれているのですが、こいつが意外と面倒です。テストのたびに react context に入っている client をモックしたり、リクエストとレスポンスを表すデータをせっせと組み上げたり、テストしたい事柄とは関係ない準備がたくさん必要になり、テストのコストが増加します。これと同じ理由で、スナップショットテストや storybook といったレンダリングの結果だけが必要なツールも利用しづらくなっています。

また、handleChangehandleSubmit の動作を確認するためには、実際に DOM を操作してイベントを発火させる必要があります。幸い React コンポーネントのテストでよく使われる enzyme では simulate などのメソッドを使って実現可能ですが、DOM の構造に依存している以上、デザイナーが構造を変更するとテストが壊れるといった問題がたびたび発生します。テストが壊れづらい CSS セレクタの書き方に頭を悩ませるのは、どうにも本質的ではない感じがしてきます。

カスタムフックを使ったリファクタリング

では、上で書いたコードをカスタムフックで整理していきます。

まずは「ロジックの再利用性が低い」という問題を抱えていた handleSubmit の内部を整理してみましょう。以下の usePost は「post 前後で適切に message を設定する」というロジックを抜き出したフックです。

const usePost = () => {
  const [message, setMessage] = React.useState<string | undefined>(undefined);
  return {
    message,
    wrappedPost: React.useCallback(
      async (body: string) => {
        setMessage('投稿中です。');
        // body を REST API 経由で投稿する
        await post(body);
        setMessage('投稿が完了しました。');
      },
      [setMessage, post]
    ),
  };
};

message を使いたいコンポーネントでは post ではなく wrappedPost を使うことになります。このようにすることで、post にまつわる状態操作のロジックを再利用することができます。また、React Hooks Testing Library 等を使うことにより、実際のフォームや他のロジックと独立にテストをすることができます。

次に、テストがしづらかった handleChangehandleSubmit を抜き出してフックにします。以下の useMyFormHook が対応するフックになります。

type MyFormHook = {
  body: string;
  message: string | undefined;
  handleChange: React.ChangeEventHandler<HTMLInputElement>;
  handleSubmit: React.FormEventHandler<HTMLFormElement>;
}

const useMyFormHook = (): MyFormHook => {
  const [body, setBody] = React.useState('');
  const { message, wrappedPost } = usePost();
  return {
    body,
    message,
    handleChange: React.useCallback<React.ChangeEventHandler<HTMLInputElement>>(
      e => {
        if (e.target.name === 'body') setBody(e.target.value);
      },
      [setBody]
    ),
    handleSubmit: React.useCallback<React.FormEventHandler<HTMLFormElement>>(
      async e => {
        e.preventDefault();
        if (window.confirm('本当に投稿しますか?')) {
          await wrappedPost(body);
        }
      },
      [body, wrappedPost]
    ),
  };
};

この整理の仕方は、MVPパターンにおけるP(Presenter)に相当すると考えることができそうです。このように整理することで、usePost と同じように実際のフォームと独立にテストをすることができます。特に面白いのは、こうした境界で handleChange 等を分割することで、ユーザーのアクションを単なる JS のオブジェクトで表現してテストできるようになっていることです。例えば、フォームの body が変化したイベントは { target: { name: 'body', value: 'new body' } }handleChange の引数として与えることで表現できます(このあたりは、実際のイベントを発火させるべき、もっとリッチなイベント生成ライブラリを使うべきといった話題もあるようで、さらに議論を深めることはできそうです)。

最後にフォームのコンポーネントを実装します。

const MyFormView: React.FC<GetMeQueryResult & MyFormHook> = ({
  data,
  loading,
  error,
  body,
  message,
  handleChange,
  handleSubmit,
}) => {
  if (loading) return <p>ロード中…</p>;
  if (error) return <p>エラーです。</p>;
  return (
    <form onSubmit={handleSubmit}>
      {message === undefined ? <div>{message}</div> : null}
      <span>{`ユーザー名「${data!.me.name}」として投稿します。`}</span>
      <input name={'body'} value={body} onChange={handleChange} />
      <button>投稿</button>
    </form>
  );
}

const MyForm: React.FC = () => {
  // ログイン中の自分のアカウントを取得する
  const queryResult: GetMeQueryResult = useQuery(getMeQuery);
  const myFormHook = useMyFormHook();
  return <MyFormView {...queryResult} {...myFormHook} />
};

フックから得られた値を使って ReactElement を構築する部分を、さらに MyFormView として分離しているのがポイントです。このようにすることでビューが pure な関数になるので、スナップショットテストや storybook といったツールでの取り回しが非常に良くなります。フックの返り値の型を mixin するような感覚でビューの props の型を定義すれば良いので、ビューコンポーネントを分割するコストはほとんど感じません。

結局何が良くなったか

ロジック、つまり「イベントを受けて状態を変化させる処理」をカスタムフックとして切り出し、さらに ReactElement を構築する処理を別コンポーネントに分割することで、ロジックとビューを疎結合にしたのが本質的にやったことです。一般的な設計論に従えば、カスタムフックで Presenter を実装して、MVP パターンでコンポーネントを整理したということもできそうです。このようにすることで、再利用性が高くテストしやすいコードになりました。また、MVP パターンに当てはめて考えられるようになったことで、カスタムフックの肥大化が設計の黄色信号である、ということも容易に理解できるようになりました。カスタムフックはあくまで「イベントを受けて状態を変化させる処理」に集中し、それ以外のドメインロジックを Model に蓄積していくと良いコードが書けそうです。