Location via proxy:   [ UP ]  
[Report a bug]   [Manage cookies]                
🐳

React Hook Formを1年以上運用してきたちょっと良く使うためのTips in ログラス(と現状の課題)

2022/03/25に公開
6

はじめに

早いものでこちらの記事が公開して約1年、ログラスでReactを書き始めて1年以上が経ちました。
https://zenn.dev/yuitosato/articles/9db2a0fe90313e

今回はフロントエンドのアプリの中でも特段重要なフォーム、特にReact Hook Formについての解説をしていきます。
今回のTipsは公式がベストプラクティスとして発表しているものではなく、あくまで個人が1年間の経験の上で良いとしているものであしからず。

なるべく何故良いかの説明もしていきます。

目次

  1. useFormをラップしてタイプセーフにする
  2. React Hook Formへの依存するコンポーネントを分ける
  3. yupを使って見通しの良いバリデーションを実装する

1. useFormをラップしてタイプセーフにする

ログラスでは useForm をそのまま使うことはせずラップしています。理由は一部の型づけがゆるく実行時例外が起きる可能性があるためです。
問題なのは defaultValues です。
実際にはこのような形でラップしています。

import { useForm, UseFormProps, UseFormReturn } from 'react-hook-form';

const useDefaultForm = <FORM_TYPE>(props: UseFormProps<FORM_TYPE> & {
  defaultValues: FORM_TYPE;
}): UseFormReturn<FORM_TYPE> => {
  return useForm(props);
}

export { useDefaultForm };

useFormdefaultValuesPartial<FORM_TYPE> | undefined であり、型どおりの初期化を強制できないためです。以下が例です。

type Form = {
  userId: string;
  userName: string;
}

const { getValues } = useForm<Form>({
  defaultValues: {
    userId: '',
    // userNameは初期化しないが型としては通る
  }
});

useEffect(() => {
  const formValue = getValues();
  console.log(formValue.userName);
  // 型としてはstring判定だが、実際はundefined
}, []);

return <input {...register('userId') } />

また、 defaultValues は設定しなくてもコンパイルは通ります。(React Hook Formの思想は非制御であり、registerが評価されるまではフォームが初期化されないのでおかしいことではないです。)

const { getValues } = useForm<Form>();

useEffect(() => {
  const formValue = getValues();
  console.log(formValue.userName);
  // 実行時エラー
}, []);

return <input {...register('userId') } />

このように意図せず型を違反してundefinedが返ってきたり、実行時エラーになるときがあります。
これを解決するために useForm をラップして、 defaultValuesPartial<Form_TYPE> | undefined ではなく FORM_TYPE に上書きします。

const useDefaultForm = <FORM_TYPE>(props: UseFormProps<FORM_TYPE> & {
  defaultValues: FORM_TYPE;
});
type Form = {
  userId: string;
  userName: string;
}

// NG: defaultValuesがないのでコンパイルエラー
const { getValues } = useDefaultForm<Form>();

// NG: userNameがないのでコンパイルエラー
const { getValues } = useDefualtForm<Form>({
  defaultValues: {
    userId: string;
  }
});

// OK
const { getValues } = useDefaultForm<Form>({
  defaultValues: {
    userId: '',
    userName: '',
  }
});

useEffect(() => {
  console.log(getValues());
  // => {
  //   userId: ''
  //   userName: ''
  // }
}, []);

2. React Hook Formに依存するコンポーネントを分ける

ログラスではReact Hook Formへの依存するコンポーネントとそうでないコンポーネントを分けています。
React Hook Formに依存しないコンポーネントを InputSelect とするなら、依存するコンポーネントはそれらのコンポーネントをラップした InputControlSelectControl という名前にしています。

React Hook Formに依存しない Input.tsx

import React, { ReactElement, Ref } from 'react';

type InputProps = {
  value: string;
  onChange: (value: string) => void;
  onBlur?: () => void;
  inputRef?: Ref<HTMLInputElement>;
  // propsはもっと多いですが、省略しています。
}

function Input({
  value,
  onChange,
  onBlur,
  inputRef,
}: InputProps): JSX.Element {
  return <input
    ref={inputRef}
    onChange={(e) => onChange(e.target.value)}
    onBlur={onBlur}
    value={value}
   />;
}

React Hook Formに依存する InputControl.tsx

import React, { ReactElement } from 'react';
import { Control, FieldPath, useController } from 'react-hook-form';
import { Input } from '~/common/components/input/Input';

type InputControlProps<FORM_TYPE> = {
  name: FieldPath<FORM_TYPE>; // FieldPathを使うことでControlに渡したジェネリクスの型を読み取ってタイプセーフになる
  control: Control<FORM_TYPE>;
}

function InputControl<FORM_TYPE>({
  name,
  control,
}: InputControlProps<FORM_TYPE>): JSX.Element {
  const { field } = useController({
    name,
    control,
  });

  return (
    <Input
      inputRef={field.ref}
      onChange={field.onChange}
      onBlur={field.onBlur}
      value={field.value as string)}
    />
  );
}

使い方

const { control } = useDeafultForm<Form>({
  defaultValues: {
    userName: '',
  },
}));

// controlを渡す
<InputControl name={'userName'} control={control} />

なぜこのようなことをするのか?

これは制御コンポーネントとして InputSelect を使える余地を残しておくためです。
アプリケーションの全てのユーザー入力は当たり前ですが、React Hook Formとともに実装する必要はありません。

具体的には入力項目が1つしか無い検索フォームやモード切替のためのチェックボックスなどは単純な useState を使った制御コンポーネントとして実装したほうがシンプルです。

const [searchValue, setSearchValue] = useState('');

// InputControlではなく、Input
<Input value={searchValue}, onChange={setSearchValue} />

発想としてはマナリンクさんのものを参考にさせていただいております。
https://zenn.dev/manalink/articles/manalink-react-hook-form-v7

こちらの記事も参考になると思います。
https://koprowski.it/react-native-form-validation-with-react-hook-form-usecontroller/

Inputなどの汎用的なコンポーネントの作り方はReact Hook Formのこちらのドキュメントを参考にして作っています。
https://react-hook-form.com/api/usecontroller

フォームコンポーネントの設計の課題感

自作で汎用コンポーネントを作るだけなら useController ではなく register で実装するだけでいいのではと感じています。
useControllerはもともと、Material-UIなどのvalue引数を受け取るような制御コンポーネントでの使用を想定しています。
しかしChakra-UIなどのように非制御コンポーネントとしても使えるUIコンポーネントは useController を使わずともいきなり register を使ってReact Hook Formと連携することができます。

https://chakra-ui.com/guides/integrations/with-hook-form

import {
  FormErrorMessage,
  FormLabel,
  FormControl,
  Input,
  Button,
} from '@chakra-ui/react'

<Input
  {...register('name', {
    required: 'This is required',
    minLength: { value: 4, message: 'Minimum length should be 4' },
  })}
/>

この場合は InputコンポーネントはReact Hook Formに依存していないかつ、 InputControlなどのようにReact Hook Formをラップしたコンポーネントをわざわざ作らなくてすみます。
この辺の研究はまだまだ深堀りができそうです。

3. yupを使って見通しの良いバリデーションを実装する

ログラスのフロントエンドのバリデーションでは yup を使っています。
React Hook Formはyupを公式サポートしていて、導入もこちらの記事を読みながら行えば簡単です。

https://react-hook-form.com/jp/get-started#SchemaValidation

type Form = {
  userId: string;
  userName: string;
}

const validationSchema:  = object().shape({
  userId: string().required(),
  userName: string().required(),
});

function SampleForm() {
  // バリデーションを定義

  const resolver =yupResolver(validationSchema);

  const { control, handleSubmit } = useDefaultForm<Form>({
    defaultValues: {
      userId: '',
      userName: '',
    },
    resolver,
  });

  return <div>
    <Input name={'userId'} control={control} />
    <Input name={'userName'} control={control} />
  </div>;
}

yupを使う利点は特にバリデーション宣言を一箇所にまとめられる点です。

一方register を使ったReact Hook Formでも同じようにバリデーションを定義できますが、DOM層に宣言が散らばってしまいます。

これは好みの問題ですが、ログラスとしてはDOM層にはロジックをあまり書かず、純粋にDOMを積み上げるような形で実装にしていきたいため、React Hook Formのデフォルトのバリデーションは使っていません。

// 中略

<div>
  <Input {...register('userId', {
    required: true // DOM層にバリデーションロジックが入り込んでしまう
  })} />
  <Input {...register('userName', {
    required: true
  })} />
</div>;

カスタムバリデーション

カスタムバリデーションは Yup の addMethod を使って実現しています。

validator.ts

// バリデーターを定義。こちらは純粋な関数なのでテストが容易に書ける
const maxLengthValidator = (
  value: unknown,
  label: string,
  max: number,
  { path, createError }: ValidationContext
): ValidateResult => {
  if (typeof value !== 'string') return true;
  const isValid = value.length <= max;
  return isValid
    ? true
    : createError({
        path,
        message: `${label}${max}文字以下で入力してください(${
          value.length - max
        }文字超過しています)`,
      });
};

// yupに maxLength というバリデーションが追加される。
yup.addMethod(yup.string, 'maxLength', function ({ label, max }: { label: string; max: number }) {
  return this.test('maxLength', '', function (value: unknown) {
    return maxLengthValidator(value, label, max, this);
  });
});

// 最後にexport
export const validator = yup;

このままでは Typesciptの型として追加されていないので、別で関数のインターフェースを宣言します。

yup.d.ts

declare module 'yup' {
  interface StringSchema {
    maxLength({ label: string, max: number }): StringSchema;
  }
}

使い方

import React, { useMemo } from 'react';
import { yupResolver } from '@hookform/resolvers/yup';
import { object, SchemaOf } from 'yup';
import { Resolver } from 'react-hook-form';
// yupではなく、addMethodでカスタムバリデーションを上書きしたインスタンスを使う
import { validator } from '~/common/utils/validator';

type Form = {
  userName: string;
}

function SampleForm() {
  // バリデーションを定義
  const validationSchema = validator.object().shape({
    // 型として maxLength が定義されている
    userName: validator.string().required().maxLength({
      label: 'ユーザー名',
      max: '4',
    }),
  });
  const resolver = yupResolver(validationSchema);

  const { control, formState } = useDefaultForm<Form>({
    defaultValues: {
      userName: '',
    },
    resolver,
  });

  return <div>
    <Input name={'userName'} control={control} />
    <p>{formState.errors.userName?.message}</p>
  <div>
}

https://storage.googleapis.com/zenn-user-upload/496a821ac7e2-20220321.png

バリデーションの課題感

上記のyupを使ったバリデーションの課題感はタイプセーフにできない点です。

type Form = {
  userId: string;
  userName: string;
}

function SampleForm() {
  const validationSchema:  = object().shape({
    userId: string().required(),
    userName: string().required(),
    hogehoge: string().required(), // Formのタイプにないのでコンパイルで落ちてほしいが落ちない
  });
  const resolver =yupResolver(validationSchema);

  const { control, handleSubmit } = useDefaultForm<Form>({
    defaultValues: {
      userId: '',
      userName: '',
    },
    resolver,
  });
}

上記の例だと、 userIduserName 以外のスキーマを定義したときにコンパイルエラーで落ちてほしいですが、コンパイルが通ってしまいます。
これだとtypoでバリデーションをかけ忘れてしまうという場合もありますのでフォームのタイプとバリデーションスキーマをあわせるようにすれば良くなると思っています。

これにはyupのスキーマから型を自動生成する方式が考えられますが、そのへんはまだチャレンジできていません。
何か簡単に実現できる方いればお教えいただけると幸いです。

おわりに

1年間ちょっと React Hook Formを使ってよかったことを書き出してみました。繰り返しですが、今回の例はあくまでイチ企業での取り組みであり、公式が発表するベストプラクティスではありません。
React Hook Formの非制御や制御などの思想感を明確に反映できていない可能性もありますし、もっと良い書き方もあるかもしれません。

もっとスマートなやり方あるぜ!という方はコメントや Twitter の@Yuiiitototまでご連絡いただけると幸いです。あとフロントエンドエンジニアのご応募お待ちしてます!

https://twitter.com/Yuiiitoto

Discussion

Ernest CroftErnest Croft

これにはyupのスキーマから型を自動生成する方式が考えられます

この方針を強く推奨します。Yup や Zod って「バリデーション」の文脈で語られることが多いですが、実は「スキーマ構築」がコンセプトなんですよね。

Yup is a schema builder for runtime value parsing and validation.

Zod is a TypeScript-first schema declaration and validation library.

じゃあスキーマとは??? という話なんですが、「型よりリッチな表現力を持つ、データ形式を定義するためのもの」だとぼくは捉えています。一番似ているのは DB のテーブル定義で、あれよりも、もう少しつよい。


少なくとも TypeScript で Web アプリケーションフロントエンドをやっていくに当たって、型には大きく2つの弱点があります。

一つは、未知のデータに型を付けるには力技に頼る必要があること。すなわち、API を叩いて返ってきた JSON どうすんの問題です。真面目にどうにかしようと思うと省力のしの字もないようなコードを書く必要があり、そんなのめんどくさいから return response.data as DataType するね……みたいになりがち。

もう一つは、単純に表現力が足りていないこと。id は UUID だし、phone は電話番号だし、oyasumiDayyyyyMMdd 表現の文字列(誰だよこんなテーブル定義するような案件請けてきたの……)なんだったとしても、TypeScript の型ではぜんぶ string と書くしかありません。

こうした問題は「バリデーション」という文脈に落とし込むこともできますが、実のところ各々のフィールド自体の特性に由来するものであって、それなら何かもっと情報源として根源的なところに定義されていてほしい感じがします。DDD で言うところの Value Object みたいな。なんかそういうの、ないかな……アッ!!!!!


そこでスキーマの出番、というわけです。型を最初に書いて型ファーストでやるんではなく、スキーマを最初に書いてスキーマファーストでやる。

さらに、スキーマはデータ形式の詳細な仕様(よりリッチな型)として機能するだけでなく、「未知のデータに対する検証とパース」を行えるので、型安全よりもつよい「スキーマ安全」なデータを提供することができます。Yup なら .validateSync、Zod なら .parse とか。

考えてみれば API レスポンスも、ユーザーのフォーム入力値も、どちらも「未知のデータ」です。なので、スキーマをフォームバリデーションのためだけに使うのはもったいない。レスポンスデータもフォーム入力値もバグレポートも睡眠時間も、とりあえずスキーマにぶち込んでおけば大体なんかうまくいくんです。

スキーマファースト、おすすめです。


余談ですが、本格的にスキーマファーストでやっていこうとする場合、Yup より Zod のほうが向いています。Zod はオブジェクト型の構築済みスキーマから .shape でフィールドスキーマを取り出し、再加工や定義値の参照ができるからです。

取り出せることの何が素晴らしいかというと「意味的に同じ項目だけど、場所によって入力ルールが違う」という状況に対応しやすくなります。例えば、ユーザープロフィールの編集で「パスワード欄になんか入ってたときだけパスワードを更新する」みたいなの、たまにありますよね。そういうとき Zod なら、

const userSettingsForm = z.object({
  password: userSchema.shape.password.or(z.string()), // または .optional() をつける
})

みたいなことができます。たすかる!

他にも、スキーマのルール値(min / max とか)を後から参照することもできるので、

<input maxlength={schema.shape.field.maxLength} />

みたいなことができたり。夢が広がりますね!

Yuito SatoYuito Sato

Ernest様
ご丁寧なお返事ありがとうございます!非常に参考になりました。
やはり最先端はスキーマファーストなフォーム開発なのですね。スキーマについてはtsとも相性がいいzodとも注目していましたが、やはりそうなのですね。

ありがとうございます。また時間があるときにこの辺は調査してみたいなと思いました!

ZUKU(お役に立てたら👍イイねオネガイシマース)ZUKU(お役に立てたら👍イイねオネガイシマース)

当然のコメント失礼します。
一点小さなことだとは思うのですが質問があります。
こちらでpropsを直接渡さず、スプレッドして渡している意図はpropsがオブジェクトであることのわかりやすさ?のためでしょうか。そのまま渡してしまっても良い気がしました。

 return useForm({...props});
Yuito SatoYuito Sato

ZAKIMAZ様
ご指摘ありがとうございます。おっしゃるとおりなので修正しておきました!

dyoshikawadyoshikawa

こちらの記事とても参考になりました。ありがとうございます。

手元で useDefaultForm を書いて試したところ、以下のコンパイルエラーとなりました。

src/use-default-form.ts:9:23 - error TS2344: Type 'T' does not satisfy the constraint 'FieldValues'.

9   props: UseFormProps<T> & {
                        ~

  src/use-default-form.ts:8:25
    8 const useDefaultForm = <T>(
                              ~
    This type parameter might need an `extends FieldValues` constraint.

エラーメッセージの通り extends FieldValues を付与することで解決しました。

import {
  FieldValues,
  useForm,
  UseFormProps,
  UseFormReturn,
} from "react-hook-form";

-const useDefaultForm = <T>(
+const useDefaultForm = <T extends FieldValues>(
  props: UseFormProps<T> & {
    defaultValues: T;
  }
): UseFormReturn<T> => {
  return useForm(props);
};

export { useDefaultForm };

執筆当時からのTSやreact-hook-formのバージョンアップデートで状況が変わったのかな?と思いますが報告いたします。

  • typescript 4.9.4
  • react-hook-form 7.41.5
Yuito SatoYuito Sato

ありがとうございます!たしかに型周りは結構変更入っているので折を見てアップデートします!