React Hook Form でAPIでのget/postを見越した入力フォームを作る際の勘所

フロントエンドエンジニアの茶木です。
さくさく動くフォームのいいですよね。そこで React Hook Form の登場です。

実務で使うときは getApi を受けたり postApi で送信したりと連携が必要になってきます。この辺を踏まえてどうコーディングするか見えてきた感があるのでまとめます。

目次

  • ファイル構成
  • useFormで管理する型を決める
  • useController を書く
  • バリデーションのスキーマを書く
  • 複数のフィールドの値をまたいでバリデーションする

ファイル構成

  • NewForm.tsx 新規フォーム
  • EditForm.tsx 編集フォーム
  • Form.tsx フォーム(新規と編集で共通)
  • FormController.tsx フォームのフィールド
  • validator.ts バリデーション
// 新規の空のフォームを作る
export const NewForm = () => {
  const submit = useCallback(async (location: Location) => postApi(location));
  const defaultValues: FormInputs = {
    title: "",
    address: "",
    coordinates: {
      longitude: 35.0;
      latitude: 139.0;
    },
    isAutocomplete: false;
  }
  return (
    <Form defaultValues={defaultValues} submit={submit}
  );
}
// apiから呼び出して埋めたの編集用のフォームを作る
interface Props = {id: string}
export const EditForm = ({ id }: Props) => {
  const submit = useCallback(async (location: Location) => putApi(location));
  const [defaultValues, setDefaultValues] = useState<FormInputs>();
  useEffect(()=>{
    (async () => {
      const location = await getApi(id));
      setDefaultValues({location, isAutocomplete: false});
    })();
  },[]);

  if(!defaultValues) return <>Loading...<>
  return (
    <Form defaultValues={defaultValues} submit={submit}
  );
}
// 共通のフォーム
interface Props = {defaultValues: FormInputs; submit: (location: Location) => void;}
export const Form = ({defaultValues, submit}: Props) => {
  const { control } = useForm<FormInputs>({
    resolver: yupResolver(validatorSchema),
    mode: "onChange",
    defaultValues,
  });
  return (
    <FormController control={control} submit={submit} />
  );
}

validator.tsFormController.tsx のソースは以降で順に挙げていきます

useForm で管理する型を決める

useForm を使うときは以下のようになります。
この useForm で管理するフォームのフィールドの型 FormInputs (型名は任意)をどのような形にするかで快適さが8割決まると言っても良いです。

  const { control, trigger } = useForm<FormInputs>({
    resolver: yupResolver(validatorSchema),
    mode: "onChange",
    defaultValues,
  });

getApi を想像して FormInputs を設計する

たとえば、ロケーションを入力するフォームがあるとします。

interface FormInputs {
  title: string;
  address: string;
  isAutocomplete: boolean;
  longitude: number;
  latitude: number;
}

こんな風にフォームの形をそのまま型にトレースしたくなるかもしれませんが、getApipostApi で受け取ったり渡したりするならば、加工が手間になるケースが想定されるので、オブジェクトの形でそのまま FormInputsにもたせます。 api に含まないフィールドはバラして FormInputs にもたせます。この例だと isAutocomplete ですね。

// APIで受け取ったり渡したりする型
interface Location {
  id?: string;
  address: string;
  coordinates: {
    longitude: number;
    latitude: number;
  }
}

interface FormInputs {
  shopLocation: Location;
  isAutocomplete: boolean;
}

useController を書く

先にソースを示します。

interface Props = {control: Control<FormInputs>; submit: (location: Location) => void;}
export const Form = ({control, submit}: ControllerProps) => {
  const location = useController({ control, name: "location" });
  const title = useController({ control, name: "location.title" });
  const address = useController({ control, name: "location.address" });
  const coordinates = useController({ control, name: "location.coordinates" });
  const longitude = useController({ control, name: "location.coordinates.longitude" });
  const latitude = useController({ control, name: "location.coordinates.latitude" });
  const isAutocomplete = useController({ control, name: "isAutocomplete" });
  return (
    <>
      <label>
        地名 <TextField {...title.field}>
        {title.fieldState.error?.message}
      <label>
      <label>
        住所 <TextField {...address.field}>
        {address.fieldState.error?.message}
      <label>
      <label>
        住所から自動で取得する <Checkbox {....isAutocomplete.field}>
      <label>       
      <label>緯度 <TextField {...longitude.field}><label>
      <label>経度 <TextField {...latitude.field}><label>
      {coordinates.fieldState.error?.message}
      <button 
        onClick={() => submit(location.field.value)} 
        disabled={Boolean(location.fieldState.error)}
      >保存</button>
    </>  
  );
}

まず、注目してほしいのは、name: "location"location が取れるのは当然として、 titlename: "location.title" という. のチェーン記法で取得できる点です。このため useForm で オブジェクトの形で渡しても大丈夫なのです。

const location = useController({ control, name: "location" });
const title = useController({ control, name: "location.title" });

続いて、値の渡し方とエラーメッセージについてです。

{…title.field}onChange={title.field.onChange} defaultValue={title.field.defaultValue} を意図してスプレッド記法で書いています。(つまり他にも nameonBlur など他の値も渡っています)

そして title.fieldState.error?.message がバリデーション結果の message になります

<label>
  地名 <TextField {...title.field}>
  {title.fieldState.error?.message}
<label>

続いて submit の箇所です。

location.field.valuesubmit に渡すことで、api に必要な props をすべて渡せています。id など フォームのフィールドに存在しないが渡さなくてはいけないものを含み、isAutocomplete などフォームのフィールドではあるが apiに渡さないものが省かれます。これが useFormapi でやりとりする Location をそのまま渡した理由です。

さらに、disabled です。location.fieldState.error は自身はもちろん、下位の titleaddress で起きたエラーがあれば保持し、エラーがないときは undefined になるのでそのまま ボタンの disabled に使えます。

<button 
  onClick={() => submit(location.field.value)} 
  disabled={Boolean(location.fieldState.error)}
>保存</button>

バリデーションのスキーマを書く

export const validatorSchema = yup.object({
  location: yup.object({
    title: yup
      .string()
      .max(
        60,
        "地名は${max}文字以内で入力してください"
      )
      .required("地名を入力してください"),
    address: yup
      .string()
      .required("住所を入力してください"),
    coordinates: yup
      .object()
      .test(
        "coordinates",
        "国内の緯度経度である必要があります",
        (coordinates) => isDomestic(coordinates)
      ),
  }),
  isAutocomplete: yup
    .bool()
    .test(
      "coordinates-empty",
      "緯度・経度を入力する必要があります",
      (isAutocomplete, context) => {
        if (isAutocomplete) return true;
        const { longitude, latitude } = context.parent.location.coordinates;
        return longitude && latitude;
      }
    ),
});

バリデーションは Yup を使います。required や最大文字数などは用意されているので簡単に判定できます。このようにして作った schemauseForm を作るときに yupResolver を通して resolver に渡してやります。

複数のフィールドの値をまたいでバリデーションする

同一の親を持つフィールドの値をまたいでバリデーションする(研究中)

以下は、最適解を模索中のものです。

たとえば、緯度・経度の両方を見て、日本国内でなければエラーを出すといったような場合です。

これは longitudelatitude をメンバーに持つ親の coordinates で判定を書けば良く、フォームのフィールドは、それぞれ 緯度経度の controller を、エラーの表示箇所には coordinatescontroller を使えば良さそうですね。

coordinates: yup
  .object()
  .test(
    "coordinates",
    "国内の緯度経度である必要があります",
    (coordinates) => isDomestic(coordinates)
  ),

と、思ったのですが、以下のようにして coodinates.fieldonChange を通して値を書き換えないと、エラーメッセージが更新されませんでした。 これだとちょっと使いにくいですね・・・

<label>緯度 <TextField 
  defaultValue={longitude.field.value} 
  onChange={(e) => coodinates.field.onChange({ 
    ...coodinates.field.value,
    longitude: e.target.value
  })}
><label>
<label>経度 <TextField 
  defaultValue={latitude.field.value} 
  onChange={(e) => coodinates.field.onChange({ 
    ...coodinates.field.value,
    latitude: e.target.value
  })}
><label>

同一の親を持たないフィールドの値をまたいでバリデーションする(研究中)

こちらもきれいな解決法が、見いだせていないケースです。
たとえば、緯度経度は入力必須だが、isAutocompletetrue のときは入力が不要になるといった場合です。

isAutocompletecoodinates の配下に入れてしまえば、 coordinates のバリデーションとして書けるのですが、api にわたす前に、isAutocomplete を除去する一手間がかかるようになるので、いい方法がないかと模索しています。

isAutocomplete: yup
  .bool()
  .test(
    "coordinates-empty",
    "緯度・経度を入力する必要があります",
    (isAutocomplete, context) => {
      if (isAutocomplete) return true;
      const { longitude, latitude } = context.parent.location.coordinates;
      return longitude && latitude;
    }
  ),

上記は、一案で textcontext から context.parent と登ることで他のフィールドにアクセスできるので、 isAutocomplete から 緯度経度を見に行き判定をしています。

ただし、これは問題が2つあります。

ひとつは、緯度経度のバリデーションは、 coordinates に書くのが自然だと思われるのですが、 isAutocomplete の方に書いている点です。なぜかというと、coordinatesparent からは isAutocomplete にたどり着けないからです。(parentのチェーンはできない)

ふたつめは、もっと深刻で 緯度経度の onChange では isAutocomplete のバリデーションが走らないことです。解決方法のひとつは useForm の際に trigger を取得して、 trigger("isAutocomplete") を 緯度経度の onChange の際に手動で呼んでやることですが、いまいちスマートじゃないですよね

・・・うむむ。

おわりに

フォームとフォームのバリデーションは奥が深いですね!

ひきつづき研究をしていきます!

Gaji-Laboでは、React経験が豊富なフロントエンドエンジニアを募集しています

弊社ではReactの知見で事業作りに貢献したいフロントエンドエンジニアを募集しています。大きな制作会社や事業会社とはひと味もふた味も違うGaji-Laboを味わいに来ませんか?

もちろん、一緒にお仕事をしてくださるパートナーさんも随時募集中です。まずはお気軽に声をかけてください。お仕事お問い合わせや採用への応募、共に大歓迎です!

求人応募してみる!

投稿者 Chaki Hironori

webライターもやってるフロントエンドエンジニアです。Reactは自信があります。またデザイン畑の出身で、気持ちのいいアニメーションやインタラクティブな表現は丁寧に手掛けます。好きなものは中南米の遺跡で、スペイン語が少しできます。