39
Help us understand the problem. What are the problem?

posted at

updated at

React 18のSuspenseを使ってReact Hook Formの非同期な初期値の扱いを簡単にする

はじめに

普段はスタートアップで建設業界向けのSaaSの開発をしているtaroと申します。
今回はReact18で登場したSuspenseを触っていたら、React Hook Formでフォームの初期値に非同期な値を設定するのが簡単になりそうだなーって思ったので、試してみました。
React Hook Formを使ったことがない方でもきっとわかるような内容になっていると思うので、ぜひぜひ読んでみてくださいー!

この記事はこちらのイベントに参加しています。

前提を揃えるためにReact Hook Formを少し復習

本題に入る前に、React Hook Formについて少し復習して前提を揃えていこうと思います。
(「復習はいらないよー!」って方は、React Hook FormでSuspenseを使うまで飛んでください!)

またSuspenseについては、公式ドキュメントや別の記事等をご参考ください。
特にuhyoさんの記事がとてもわかりやすくまとまっているのでおすすめです!

React Hook Formとは?

React Hook FormはHooksベースでFormを管理できるのが特徴で、Reactのフォーム管理だと一位を争うほどよく採用されているライブラリです。

簡単なフォームを作ってみる

今回はReact Hook Formを使って、名前と年齢を入力してコンソールに出力する簡単なフォームを作ってみます。
画面収録-2022-07-17-6.08.30.gif

フォームのComponentはこちらです。

import { FC } from "react";
import { useForm } from "react-hook-form";

type Inputs = {
  name: string;
  age: number;
};

const Form: FC = () => {
  const { register, handleSubmit } = useForm<Inputs>();
  const onSubmit = (data: Inputs) => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("name")} type="text" placeholder="氏名" />
      <input {...register("age")} type="number" placeholder="年齢" />
      <input type="submit" />
    </form>
  );
};

このフォームでReact Hook Form特有の書き方をしている場所は3箇所です。

useFormを呼び出してフォームを初期化し、フォームを管理するためのメソッドを受け取る

const { register, handleSubmit } = useForm<Inputs>();

registerで各inputをReact Hook Formの管理下に登録する
React Hook Formではname属性(今回はnameage)を使って、各inputを識別して管理します。

<input {...register("name")} type="text" placeholder="氏名" />
<input {...register("age")} type="number" placeholder="年齢" />

handleSubmitでReact Hook Formで管理しているフォームのデータを受け取る
handleSubmitでラップした関数(onSubmit)には、Validateされたフォームのデータが渡されます。

  const onSubmit = (data: Inputs) => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>

フォームに初期値を設定する

次にフォームに初期値を設定します。
フォームに初期値を設定する時は、useFormで初期化する際に引数に初期値を渡してあげます。

const { register, handleSubmit } = useForm<Inputs>({
  defaultValues: {
    name: "taro",
    age: 26
  }
});

簡単ですね。

ちなみに各inputに直接指定してあげることもできます。

<input {...register("name")} type="text" placeholder="氏名" defaultValue="taro" />
<input {...register("age")} type="number" placeholder="年齢" defaultValue={26} />

React Hook Formの復習はここまでです!お疲れさまでした!

フォームの初期値に非同期なデータを設定する

では前提が揃ったところで本題の「React Hook Formで非同期な初期値を設定する」を考えていきます。
そこで以下のような「2秒後に名前と年齢のデータを返す非同期な関数」の返り値を、フォームの初期値として設定する場合を考えます。

const fetchData = async () => {
  await new Promise((resolve) => setTimeout(resolve, 2000));
  return { name: "taro", age: 26 };
};

とりあえず愚直にやってみる

まずは愚直にstateで初期値を持ち、データの取得が完了したらその返り値をsetStateしてみます。

const [data, setData] = useState<Inputs>();
const { register, handleSubmit } = useForm<Inputs>({ defaultValues: data });

useEffect(() => {
  fetchData().then((data) => setData(data));
}, []);

想像がついていた方もいるかもしれませんが、この方法だとフォームに初期値は反映されません。
useFormの引数に初期値を渡す場合、useFormが呼び出された時のdata(undefined)が初期値として設定されるためです。
そのため公式ドキュメントにも記されている通り、非同期な初期値を設定する場合はresetを使うことが多いです。

resetを使って非同期な初期値を設定する。

resetはその名の通り再度フォームを初期化するメソッドで、引数にデータを渡すとその値を初期値として初期化してくれます。
ではresetを使ってデータの取得が終わったら再度フォームを初期化してあげましょう。

const [data, setData] = useState<Inputs>();
const { register, handleSubmit, reset } = useForm<Inputs>();

useEffect(() => {
  fetchData().then((data) => setData(data));
}, []);

// データの取得が完了したら再度初期化
useEffect(() => {
  reset(data);
}, [reset, data]);

これで晴れて非同期なデータを初期値として反映させることが出来ました!
またuseStateを使わずに書くことも可能です。

const { register, handleSubmit, reset } = useForm<Inputs>();

const resetAsyncForm = useCallback(async () => {
  const data = await fetchData();
  reset(data);
}, [reset]);

// データの取得が完了したら再度初期化
useEffect(() => {
  resetAsyncForm();
}, [resetAsyncForm]);

さらに次のようにLoadingを表示させると、
画面収録-2022-07-17-7.08.54.gif

Component全体はこんな感じになります。

const Form: FC = () => {
  const { register, handleSubmit, reset } = useForm<Inputs>();

  const [loading, setLoading] = useState<boolean>(true);

  const resetAsyncForm = useCallback(async () => {
    const data = await fetchData();
    reset(data);
    setLoading(false);
  }, [reset]);

  // データの取得が完了したら再度初期化
  useEffect(() => {
    resetAsyncForm();
  }, [resetAsyncForm]);

  const onSubmit = (data: Inputs) => console.log(data);

  if (loading) return <p>Loading...</p>;
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("name")} type="text" placeholder="氏名" />
      <input {...register("age")} type="number" placeholder="年齢" />
      <input type="submit" />
    </form>
  );
};

だいぶ複雑になってきましたね。。。
Custom Hooks等を使えば、もう少しシンプルになるかもしれませんが、やはりresetで再初期化するという方法を変える必要がありそうです。
またこのresetによる再初期化の方法は、React Hook Formを知らない人からすると一見何をやっているのかがわかりづらいも、個人的には気になるポイントです。

ちなみにReact Hook Formの開発者のBillさんもこの方法に対して、“honestly, this is probably not a pretty and clean approach...”とコメントしています。

そんな悩みを解決するために、今回試してみたのがSuspenseです!

React Hook FormでSuspenseを使う

ではSuspenseを使っていきましょう!
Suspenseを使うと、ComponentにLoading状態を持たせるのではなく、Component自体をLoading状態にすることができます。
そのためComponent内部ではLoading中かどうかを意識する必要がなくなり、非同期なデータの取得を副作用として扱わなくてよくなります。

そもそもresetで再度初期化をしていた理由が useFormで初期化するタイミングでは、データが取得されていなかったことだったのを思い出すと、、、
何となく上手く出来そうな気がしますよね…!

では試してみましょう!

Suspenseを使って非同期な初期値を設定する

Suspenseを使うために初期値を受け取るグローバルな変数を定義します。
(stateで扱うこともできますが、少し煩雑になるのとこちらの記事でuhyoさんが述べている理由からグローバル変数を使っています)

let data: Inputs | undefined;

次にSuspenseのお作法に沿ってPromisethrowします!
またPromiseが成功したらその返り値を初期値に代入しています。

if (data === undefined) {
  throw fetchData().then((d) => (data = d));
}

そしてフォームのComponentをSuspenseで挟んであげて、fallbackにLoading時の表示を渡します。

<Suspense fallback={<p>Loading...</p>}>
  <Form />
</Suspense>

最後にフォームのComponentから不要になった処理を消してあげると、以下のようになります。

const Form: FC = () => {
  if (data === undefined) {
    throw fetchData().then((d) => (data = d));
  }

  const { register, handleSubmit } = useForm<Inputs>({ defaultValues: data });

  const onSubmit = (data: Inputs) => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("name")} type="text" placeholder="氏名" />
      <input {...register("age")} type="number" placeholder="年齢" />
      <input type="submit" />
    </form>
  );
};

resetuseEffectがなくなり、だいぶ見通しが良くなりました!
さらにデータの取得をCutom Hooksに切り出してあげるとよりシンプルになります。

// データの取得をCustomHooksに切り出す
const useData = () => {
  if (data === undefined) {
    throw fetchData().then((d) => (data = d));
  }
  return data;
};

const Form: FC = () => {
  const { register, handleSubmit } = useForm<Inputs>({
    defaultValues: useData()
  });

  const onSubmit = (data: Inputs) => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("name")} type="text" placeholder="氏名" />
      <input {...register("age")} type="number" placeholder="年齢" />
      <input type="submit" />
    </form>
  );
};

これならReact Hook Formを知らない人が見ても、何をしているのかある程度推測できるかなーと思うので、今回はここまでで完成とします!
実際に作ったフォームはCodeSandboxにおいておくので、よかったら触ってみてください。

おまけ

ちょっとだけおまけです。

swrを使った例

swrにはまだ実験的な機能ですが、Suspense Modeがあるので簡単にSuspenseに対応させることが可能です。

const Form: FC = () => {
  const { data } = useSWR("data", fetchData, { suspense: true });

  const { register, handleSubmit } = useForm<Inputs>({ defaultValues: data });

  const onSubmit = (data: Inputs) => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("name")} type="text" placeholder="氏名" />
      <input {...register("age")} type="number" placeholder="年齢" />
      <input type="submit" />
    </form>
  );
};

ただSuspense Modeでもdataの型にundefinedが含まれてしまうなどまだ未完成のところがあり、実務で使うのはまだちょっと早いかなーという所感です。
Suspense Modeでの型のサポートについては、こちらのissueで議論されています。

まとめ

今回はReact Hook Formで非同期な初期値を設定するためにReact18で登場したSuspenseを使ってみました!
個人的にフォームはユーザーが頻繁に操作するUIなため、React18で登場した様々な機能が活きてくるんじゃないかなーとわくわくしています!React Hook Formの今後の進化も楽しみですね!
また普段はZennに記事を投稿したり、Twitterで技術のことをつぶやいているので、よかったらフォローしたり絡んだりしてもらえたらとっても嬉しいです!

記事の内容に関する感想やご指摘、質問等もあればぜひぜひお待ちしてますー!
以上、最後まで読んでいただきありがとうございました!

お世話になったページ

Suspense

React Hook Form

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
39
Help us understand the problem. What are the problem?