📝

React Hook Formの初期値に、SWRで取得した値をセットする方法を検証

2024/06/30に公開

検証したこと

いわゆる編集フォームを作成する場合に、React Hook Formの初期値をどのようにセットすればよいのかを検証しました

  1. GETリクエストでサーバーから保存済みの値を取得
  2. 取得した値をフォームに表示
  3. フォームを編集
  4. 編集した値をPUTリクエスト等でサーバーに保存

というよくあるフォームです
SWRの場合GETリクエストが最初にundefinedを返してくるので、どうするのが良い感じになるのかを検証してみました

検証に使用したライブラリ

  {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-hook-form": "^7.52.0",
    "swr": "^2.2.5",
    "vite": "^5.2.0",
    "json-server": "1.0.0-beta.1",
  }

実現したい挙動

1.サーバーの値が変化してもフォームの値は変えない

メリット

  • 入力中に意図せず値が上書きされることがないので、ユーザーは混乱しない
  • 一貫した挙動を実現しやすい

デメリット

  • 他のユーザーが同じ項目を編集することが想定される場合、先に保存したユーザーの内容が意図せず上書きされてしまう

2.サーバーの値が変化したらフォームの値を上書きする

メリット

  • 他のユーザーが値を変更した場合に、入力中のユーザーがそれに気がつくことが出来る可能性がある

デメリット

  • ユーザーが気が付かないうちに入力した値が更新され、意図しない値を保存してしまうことがある
  • ユーザーの動きによってSWRの再検証が実施される場合とされない場合があり、ユーザーにとって一貫した挙動にするのが難しい
    • SWRの初期設定だとウィンドウのフォーカスが外れて戻ってくると再検証が実施されるが、一度も再検証されずに編集完了する場合もある
    • ポーリング処理(refreshInterval)を設定していれば一定間隔で再検証されるので、最新のデータを取得できる確率は上がる

https://swr.vercel.app/ja/docs/revalidation#revalidate-on-interval

結論

※手元でサーバーの値を変えた後、ページにフォーカスを合わせてSWRにデータの再検証をさせています

1. サーバーの値が変化してもフォームの値は変えない場合

1. Suspenseを使う場合は、useSWRのdataをdefaultValuesにセットする

  • SWRがdataを返すまでコンポーネントはレンダリングされないので非常にシンプル
  • フォームの値が上書きされる場合とほぼ変わらない

2. Suspenseを使わない場合は、useSWRMutationのtriggerでGETした値をdefaultValuesにセットする

  • useSWRMutationのtrigger関数を使うことで、useFormに値をセットするタイミングでGETリクエストを呼び、結果をフォームにセットすることが出来る
  • valuesに等価でない値を入れると無限にレンダリングが繰り返されるので注意

3. React Hook Formより親のコンポーネントにuseSWRを置く場合は、dataが返ってくるまでフォームコンポーネントをマウントしない

  • サーバーからundefinedが返却された場合に判別が難しいケースがあるはず

2. サーバーの値が変化したらフォームの値を上書きする場合

1. Suspenseを使う場合はuseSWRのdataをvaluesにセットする

  • SWRがdataを返すまでコンポーネントはレンダリングされないので非常にシンプル
  • フォームの値が上書きされない場合とほぼ変わらない

2. Suspenseを使わない場合はuseSWRのdataをvaluesにセットする

  • 非常にシンプル

3. React Hook Formより親のコンポーネントにuseSWRを置く場合は、dataが返ってくるまでフォームコンポーネントをマウントしない

  • サーバーからundefinedが返却された場合に判別が難しいケースがあるはず

検証したコード

コードの概要

  • 親コンポーネントにoutOfFormとdescriptionという変数をuseStateで保持し、各子コンポーネントにpropsで渡す
    • outOfFormは子コンポーネントのformの外に表示する
    • descriptionはform内のinputに渡す
    • 親コンポーネントで再レンダリングが発生した場合の挙動を確認するために一応用意
  • 子コンポーネントでSWRを使いサーバーに保存された値をGETリクエストで取得し、React Hook Formを使いフォームに値をセットする
    • 非制御コンポーネントで検証
      • inputタグにregisterでformを紐づける書き方
  • データ取得中にユーザーが入力できないようにするため、またロード中であることがわかるようにinputにdisabled要素を付与している
    • スピナーを表示するのでも良いと思う。簡単に検証するためにこうしている
    • いつの間にか入力した値が上書きされるのに気がつきやすくするため

1. サーバーの値が変化してもフォームの値は変えない場合

1. Suspenseを使う場合は、useSWRのdataをdefaultValuesにセットする

  • data取得されるまでフォームコンポーネントがマウントされないので、データ取得中にユーザーは値を入力できないようになっている
    • ロード中にはSuspenseのfallbackが表示される
  • SSGやSSRで使う場合は、SWRにfallbackDataの付与が必要なので注意
    • 今回の検証とは異なる結果になる可能性有り
    • Nextjsでは、明示的にgetServerSidePropsやgetStaticPropsを使っていないコンポーネントでも、ハイドレーションエラーが発生する
      • Suspenseは内部でサーバーサイドでも実行されるためらしい
      • static exportの場合は使えるかもしれないが未検証

参考
https://zenn.dev/key5n/articles/06cd789d30b98c

// 親コンポーネント
<div>
  <h3>Suspense</h3>
  <Suspense fallback={<div>Loading...</div>}>
    <NoChangeSuspenseForm
      outOfForm={outOfForm}
      description={description}
    />
  </Suspense>
</div>

// フォームコンポーネント
export const NoChangeSuspenseForm = ({ outOfForm, description }: FormProps) => {
  const { data } = useFetchUserWithSuspense();
  const methods = useForm({
    defaultValues: {
      firstName: data.firstName,
      lastName: data.lastName,
      description,
    },
  });

  return (
    <div>
      <div>{outOfForm}</div>
      <form
        onSubmit={methods.handleSubmit((value) => {
          console.log(value);
        })}
      >
        <input {...methods.register("firstName")} />
        <input {...methods.register("lastName")} />
        <input {...methods.register("description")} />
        <button type="submit">Submit</button>
        <button type="button" onClick={() => methods.reset()}>
          Reset
        </button>
      </form>
    </div>
  );
};

// useSWR
export const useFetchUserWithSuspense = () => {
  return useSWR(API_PATH, fetcher, { suspense: true });
};

2. Suspenseを使わない場合は、useSWRMutationのtriggerでGETした値をdefaultValuesにセットする

  • 非同期関数のtriggerがresolveするタイミングでdefaultValuesにセットしている
  • データ取得中かどうかはuseFormのformState.isLoadingで判定している
// 親コンポーネント
<div>
  <h3>Async</h3>
  <NoChangeAsyncDefaultForm
    outOfForm={outOfForm}
    description={description}
  />
</div>

// フォームコンポーネント
export const NoChangeAsyncDefaultForm = ({
  outOfForm,
  description,
}: FormProps) => {
  const { trigger } = useFetchUserByMutation();
  const {
    formState: { isLoading },
    handleSubmit,
    register,
    reset,
  } = useForm({
    defaultValues: async () => {
      const result = await trigger();
      return { ...result, description };
    },
  });

  return (
    <div>
      <div>{outOfForm}</div>
      <form
        onSubmit={handleSubmit((value) => {
          console.log(value);
        })}
      >
        <input {...register("firstName")} disabled={isLoading} />
        <input {...register("lastName")} disabled={isLoading} />
        <input {...register("description")} disabled={isLoading} />
        <button type="submit">Submit</button>
        <button type="button" onClick={() => reset()}>
          Reset
        </button>
      </form>
    </div>
  );
};

// useSWR
export const useFetchUserByMutation = () => {
  return useSWRMutation(API_PATH, fetcher);
};

3. React Hook Formより親のコンポーネントにuseSWRを置く場合は、dataが返ってくるまでフォームコンポーネントをマウントしない

  • useSWRを置くコンポーネントがフォームより親にある場合に使えるケースもある
// 親コンポーネント
<div>
  <h3>Values</h3>
  <NoChangeValuesForm />
</div>

// フォームコンポーネント
export const NoChangePropsForm = ({ outOfForm, description }: FormProps) => {
  const { data } = useFetchUser();

  if (!data) {
    return <div>Loading...</div>;
  }

  return (
    <PropsFormChildren
      firstName={data?.firstName ?? ""}
      lastName={data?.lastName ?? ""}
      outOfForm={outOfForm}
      description={description}
    />
  );
};

type Props = {
  firstName: string;
  lastName: string;
};

const PropsFormChildren = ({
  firstName,
  lastName,
  outOfForm,
  description,
}: Props & FormProps) => {
  const methods = useForm({
    defaultValues: {
      firstName,
      lastName,
      description,
    },
  });

  return (
    <div>
      <div>{outOfForm}</div>
      <form
        onSubmit={methods.handleSubmit((value) => {
          console.log(value);
        })}
      >
        <input {...methods.register("firstName")} />
        <input {...methods.register("lastName")} />
        <input {...methods.register("description")} />
        <button type="submit">Submit</button>
        <button type="button" onClick={() => methods.reset()}>
          Reset
        </button>
      </form>
    </div>
  );
};

// useSWR
export const useFetchUser = () => {
  return useSWR(API_PATH, fetcher, { suspense: false });
};

2. サーバーの値が変化したらフォームの値を上書きする場合

1. Suspenseを使う場合はuseSWRのdataをvaluesにセットする

  • サーバーの値が変化してもフォームの値は変えない場合との違いはdefaultValuesに値をセットしているか、valuesに値をセットしているかの違いでほぼ同じ
// 親コンポーネント
<div>
  <h3>Suspense</h3>
  <Suspense fallback={<div>Loading...</div>}>
    <ChangeSuspenseForm
      outOfForm={outOfForm}
      description={description}
    />
  </Suspense>
</div>

// フォームコンポーネント
export const ChangeSuspenseForm = ({ outOfForm, description }: FormProps) => {
  const { data, isValidating } = useFetchUserWithSuspense();
  const methods = useForm({
    defaultValues: {
      firstName: data.firstName,
      lastName: data.lastName,
      description,
    },
    values: {
      firstName: data.firstName,
      lastName: data.lastName,
      description,
    },
  });

  return (
    <div>
      <div>{outOfForm}</div>
      <form
        onSubmit={methods.handleSubmit((value) => {
          console.log(value);
        })}
      >
        <input {...methods.register("firstName")} disabled={isValidating} />
        <input {...methods.register("lastName")} disabled={isValidating} />
        <input {...methods.register("description")} disabled={isValidating} />
        <button type="submit">Submit</button>
        <button type="button" onClick={() => methods.reset()}>
          Reset
        </button>
      </form>
    </div>
  );
};

// useSWR
export const useFetchUserWithSuspense = () => {
  return useSWR(API_PATH, fetcher, { suspense: true });
};

2. Suspenseを使う場合はuseSWRのdataをvaluesにセットする

  • 特に工夫することなく書くとこうなると思う
// 親コンポーネント
<div>
  <h3>Values</h3>
  <ChangeValuesForm outOfForm={outOfForm} description={description} />
</div>

// フォームコンポーネント
export const ChangeValuesForm = ({ outOfForm, description }: FormProps) => {
  const { data, isValidating } = useFetchUser();
  const methods = useForm({
    defaultValues: {
      firstName: "",
      lastName: "",
      description: "",
    },
    values: {
      firstName: data?.firstName,
      lastName: data?.lastName,
      description,
    },
  });

  return (
    <div>
      <div>{outOfForm}</div>
      <form
        onSubmit={methods.handleSubmit((value) => {
          console.log(value);
        })}
      >
        <input {...methods.register("firstName")} disabled={isValidating} />
        <input {...methods.register("lastName")} disabled={isValidating} />
        <input {...methods.register("description")} disabled={isValidating} />
        <button type="submit">Submit</button>
        <button type="button" onClick={() => methods.reset()}>
          Reset
        </button>
      </form>
    </div>
  );
};

// useSWR
export const useFetchUser = () => {
  return useSWR(API_PATH, fetcher, { suspense: false });
};

3. React Hook Formより親のコンポーネントにuseSWRを置く場合は、dataが返ってくるまでフォームコンポーネントをマウントしない

  • useSWRを置くコンポーネントがフォームより親にある場合に使えるケースもある
// 親コンポーネント
<div>
  <h3>Props</h3>
  <ChangePropsForm outOfForm={outOfForm} description={description} />
</div>

// フォームコンポーネント
export const ChangePropsForm = ({ outOfForm, description }: FormProps) => {
  const { data, isValidating } = useFetchUser();

  if (!data) {
    return <div>Loading...</div>;
  }

  return (
    <PropsFormChildren
      firstName={data?.firstName ?? ""}
      lastName={data?.lastName ?? ""}
      outOfForm={outOfForm}
      description={description}
      isValidating={isValidating}
    />
  );
};

type Props = {
  firstName: string;
  lastName: string;
  isValidating: boolean;
};

const PropsFormChildren = ({
  firstName,
  lastName,
  outOfForm,
  description,
  isValidating,
}: Props & FormProps) => {
  const methods = useForm({
    defaultValues: {
      firstName: data.firstName,
      lastName: data.lastName,
      description,
    },
    values: {
      firstName,
      lastName,
      description,
    },
  });

  return (
    <div>
      <div>{outOfForm}</div>
      <form
        onSubmit={methods.handleSubmit((value) => {
          console.log(value);
        })}
      >
        <input {...methods.register("firstName")} disabled={isValidating} />
        <input {...methods.register("lastName")} disabled={isValidating} />
        <input {...methods.register("description")} disabled={isValidating} />
        <button type="submit">Submit</button>
        <button type="button" onClick={() => methods.reset()}>
          Reset
        </button>
      </form>
    </div>
  );
};

// useSWR
export const useFetchUser = () => {
  return useSWR(API_PATH, fetcher, { suspense: false });
};

検証でわかったこと

React Hook Formは細かく設定出来るので、各パラメータを一通り目を通しておく必要がある
今回検証したのは主にdefaultValues, values, reset

https://react-hook-form.com/docs/useform#values
https://react-hook-form.com/docs/useform#defaultValues
https://react-hook-form.com/docs/useform/reset

  1. サーバーから値を取得してフォームにセットするまでの間に、ユーザーが入力できてしまうので自分で制御しないといけない
    • 大抵の場合は入力させないようにinputをdisabledにしたり、スピナーを表示するなど制御すると親切
    • resetOptionsで挙動は変えられる
      • keepValues:true どこかの項目が入力されていたら全ての項目を上書きしない
      • keepDirtyValues: true ユーザーが入力した項目は上書きしない
  2. keepDefaultValues: trueにしていない場合、React Hook Formのreset関数を引数なしで実行すると、defaultValues, valuesに最後に反映された値になる
    • ドキュメントに書いてあるとおり
    • keepDefaultValues: true で最初にdefaultValueに入れた値になる
      • サーバーから取得した値にリセットされたではなく、全ての項目は未入力にする場合などに有効だと思われる
    • useFormの引数に渡す値次第で色々挙動が変わるので、reset関数に引数を渡すのもシンプルに実装する手段の一つとなりそう

感想

検証しても全体的にどうするのがベストかよくわかりませんでした。より良いやり方をご存知の方がいたらコメントいただけると助かります。
フォームはアプリケーション全体でできるだけ統一した動きだと、ユーザーも開発者もわかりやすいですし、ライブラリのアップデートによる破壊的変更にも対応しやすいと思います。

検証したコードのリポジトリ

https://github.com/tatsuya-asami/rhf-async-default-values

コミューン株式会社

Discussion