🖥

useSWR で作る Form 画面の備忘録

2021/09/24に公開

管理画面のような CRUD 中心のプロダクトでは Form と CSR をよく利用します。SWR は開発体験に優れたライブラリですが、Form に利用する場合注意点があるので、備忘録として共有します(広義の SWR と紛わない様に、タイトルはuseSWRとしました)

視覚的安定性とキャッシュ

はじめに、SWR や React Query を利用するモチベーションについて言及します。SWR は一意のキーに紐づいたキャッシュが無い場合、loading fallback を表示します。fallback 表示はたとえ一瞬であっても「チカっ」とした表示になるため「スムーズではない印象」を利用者に与えてしまいます。しかし SWR や React Query は有キャッシュ時は fallback を SKIP するため、ユーザー操作を妨げずに視覚的安定性を提供することができます。 このメリットは、反復画面遷移で顕著に現れます。

import useSWR from "swr";

function Profile() {
  const { data, error } = useSWR("/api/user", fetcher);

  if (error) return <div>failed to load</div>;
  if (!data) return <div>loading...</div>; // 有キャッシュ時はSKIP、画面がチカっとしない
  return <div>hello {data.name}!</div>; // 新キャッシュを得た時、差し替わる
}

視覚からくる体感パフォーマンス向上のためには 「どれだけ予測可能な UI を即時返却するか」 という意識が不可欠です。SWR や React Query のキャッシュ機構はこれに貢献しており、非同期処理がレンダリングブロックを起こさない仕組みに自然と乗る事ができます。この恩恵をどれほど意識するかが、これらのライブラリを扱う上でポイントになります。

末端コンポーネントが抱える staled value

Form + SWR で特に注意しなければいけないのは「Edit 画面」です。SWR で実装されたこの画面の処理一連を見て行きましょう。

  • 1.SWR で編集対象の初期値を取得する(GET)
  • 2.input 要素に defaultValue として渡す
  • 3.ユーザー編集がおこなわれる
  • 4.編集された値を送る(PUT)

この実装では、有キャッシュ時 staled value が末端コンポーネントに保持され、revalidated value に書き換えられないというバグが起こり得ます。つまり、前回編集したはずの内容がキャッシュにより先祖返って表示されてしまうというバグです。この事象は input 要素の視点で考えると分かりやすいです。

  • 1.初期マウント時は staled value が渡って来る(初期値としてセット)
  • 2.親コンポーネント上で revalidate が発火
  • 3.revalidated value が渡って来る
  • 4.revalidated value を正としてリセットする (これを忘れがち)

props.defaultValueで受けた値は、明示的に書き換える必要があります。これの対処として、useEffect でリセットする方法があります。一般的な制御コンポーネントを例に見ていきます。

function Template(props: { defaultValue: string }) {
  // defaultValue として保持
  const [value, setValue] = React.useState(props.defaultValue);
  const handleChange = React.useCallback(
    (event: React.ChangeEvent<HTMLInputElement>) => {
      setValue(event.target.value)
    },
    [setValue]
  );
  // defaultValue のリセット
  React.useEffect(() => {
    setValue(props.defaultValue);
  }, [setValue, props.defaultValue]);
  return <input type="text" value={value} onChange={handleChange} />;
}

export function Page() {
  const { data, error } = useSWR("/api/user/edit", () =>
    axios.get<string>("/api/user/edit").then(({ data }) => data);
  );
  if (error) return <div>failed to load</div>;
  return <Template defaultValue={data || ""} />;
}

このように、SWR を用いた「Edit 画面」では、初期値を更新する機構をはじめから考慮する必要があります。

非制御コンポーネントの場合

先の例では制御コンポーネントで示しましたが、react-hook-form などでは非制御コンポーネントを使い、defaultValueを与える実装になるかと思います。useEffect 内 で setValue を使用するアプローチがありますが、もっと効率的な方法があります。

  • [1] defaultValuesuseForm 実行時に与える
  • [2] data を useEffect の deps 配列に追加する
  • [3] useEffectreset 関数でリセットする
export function Page() {
  const { data, error } = useSWR("/api/user/edit", () =>
    axios.get<{ name: string }>("/api/user/edit").then(({ data }) => data)
  );
  const { register, reset } = useForm({
    defaultValues: { name: "" }, // [1]
  });
  React.useEffect(() => {
    reset({ name: data.name }); // [3]
  }, [data]); // [2]
  if (error) return <div>failed to load</div>;
  return <input type="text" {...register("name")} />;
}

制御コンポーネントと比較し記述量が少ないという react-hook-form の利点はそのままです。この様に useSWR と useForm はセットで使い、末端で操作しないことが望ましいです。(制御コンポーネントも同様ですね)

loading fallback は安直に使わない

ここまでのサンプルでは<div>loading...</div>による loading fallback を意図的に省略してきました。 「どれだけ予測可能な UI を即時返却するか」 という意識で取り組むと、input 要素だけは即表示し、データ取得が完了したら書き換えた方が視覚的安定性を提供できます。(列挙される input 要素が予測可能なケースに限られますが)

安直に loading fallback を表示してしまいがちですが、ここは工夫の余地があります。レイアウトが予測可能ならば、なるべくレンダリングブロックを避けるようにします。余談ですが、データ取得の如何に関わらずレイアウトできるデザインは、これからのフロントエンドで需要が高まると予測しています。

isValidating を入力拒否に活用する

今回の様なケースでは useSWR の返す isValidating は validate 中の input 要素入力拒否に活用可能です。 開発中は API レスポンスが高速に返ってくるので気づかないかもしれませんが、実際の API では、状況により数秒かかることもあるかもしれません。そのため、validate 中も入力を受け付けてしまうと 「input 要素に途中まで入力していたのに、急に先祖返ってしまった」 という不具合が発生してしまいます。これはユーザーが望むものでは無いため、isValidating は input 要素の disabled に使うのが適切ではないでしょうか。

export function Page() {
  const { data, error, isValidating } = useSWR("/api/user/edit", () =>
    axios.get<string>("/api/user/edit").then(({ data }) => data)
  );
  const { register, reset } = useForm({
    defaultValues: { name: "" },
  });
  React.useEffect(() => {
    reset({ name: data });
  }, [data]);
  if (error) return <div>failed to load</div>;
  return (
    <input
      type="text"
      disabled={isValidating} // <- ここ
      {...register("name")}
    />
  );
}

結局、非同期処理でユーザー操作をブロックしている状況にはなりますが、視覚的安定性は担保されます。この実装についてはトレードオフになるので、ステークホルダー間で議題にあげても良いかもしれません。

mutate の活用

mutate を活用すると、より視覚的安定性が向上します。ここまでの解説のとおりだと、staled value を revalidated value に書き換える瞬間は必ず生じてしまい、これがユーザーの目に留まってしまうと多少の違和感を与えてしまいます。

もし編集・更新対象のキャッシュをコンポーネントが把握して問題なければ、mutate を利用します。第三引数に false を与えると、revalidate 無しにキャッシュを書き換え可能なので、API 呼び出し回数も最小限に抑えることが出来ます。(以下公式ドキュメントより引用)

import useSWR, { useSWRConfig } from "swr";

function Profile() {
  const { mutate } = useSWRConfig();
  const { data } = useSWR("/api/user", fetcher);
  return (
    <div>
      <h1>My name is {data.name}.</h1>
      <button
        onClick={async () => {
          const newName = data.name.toUpperCase();
          // 再検証をせずに直ちにローカルデータを更新します
          mutate("/api/user", { ...data, name: newName }, false);
          // ソースを更新するために API にリクエストを送信します
          await requestUpdateUsername(newName);
          // ローカルデータが最新であることを確かめるために再検証(再取得)を起動します
          mutate("/api/user");
        }}
      >
        Uppercase my name!
      </button>
    </div>
  );
}

ただし、これはリソースキャッシュ範囲が広い場合は注意が必要です。 上記の様にユーザー詳細画面でユーザー名を変更した場合、ユーザー詳細画面のキャッシュ更新だけでは不十分な可能性が高いです。

あらゆる mutate を実行しなければならない場合、mutate 漏れが生じたり、後から追加する必要が出てきたりと、更新処理が煩雑になってしまいます。そのため Form 画面では、本稿で取り上げた staled value のリセットは一次対処として施しておいた方が無難です。

どのタイミングで初回 fetch しどこで mutate を実行するかは、設計次第で異なります。mutate で revalidate も発火する場合、API 呼び出しを 1 回実行していますので revalidateIfStale が true の場合(デフォルト)は、無闇な mutate revalidate は避けたいところです。

初期値リセットを担保するテストケース

MSW などモックサーバーで開発していると、キャッシュによる不具合に気づき辛いです。 モックレスポンスは同じレスポンスを返す事がほとんどなので、実 API と結合したタイミングで不具合に気づく事も珍しくありません。

そのため、初期値を渡し状態を抱えるコンポーネントに対しては「revalidated value に書き換えられること」というテストケースを書くべきです。MSW の場合server.use関数で handler の intercept が可能です。以下の様なテストケースを書く事で、有キャッシュ時でも revalidated value に書き換わる挙動を再現できます。

test("revalidated value に書き換えられること", async () => {
  // 無キャッシュ時レンダリング、設定済みの MSW handler レスポンスが表示されることを確認
  const renderResult = render(<Template />);
  await waitFor(() =>
    expect(renderResult.getByRole("textbox")).toHaveDisplayValue("TARO")
  );
  // ここまでで、有キャッシュ状態になる
  // 設定済みの MSW handler のレスポンスを書き換える
  server.use(
    rest.get("/api/user/edit", (_, res, ctx) => {
      return res(ctx.json({ name: "JIRO" }));
    })
  );
  // 有キャッシュで再レンダリング、書き換えた MSW handler のレスポンスが表示されることを確認
  renderResult.rerender(<Template />);
  await waitFor(() =>
    expect(renderResult.getByRole("textbox")).toHaveDisplayValue("JIRO")
  );
  renderResult.unmount();
});

まとめ

SWR は、API の正規・非正規化、UX 最適化、コンポーネント構成など、考えることは多岐に渡ります。実装者はほんの僅かな瞬間から生じる違和感に敏感である必要があるなど、掘り下げていくと難易度が高いです。一見シンプルに使えるものですが、様々なテクニックを発揮できる奥深いライブラリだと思います。

Discussion