React Hook Formを1年以上運用してきたちょっと良く使うためのTips in ログラス(と現状の課題)
はじめに
早いものでこちらの記事が公開して約1年、ログラスでReactを書き始めて1年以上が経ちました。
今回はフロントエンドのアプリの中でも特段重要なフォーム、特にReact Hook Formについての解説をしていきます。
今回のTipsは公式がベストプラクティスとして発表しているものではなく、あくまで個人が1年間の経験の上で良いとしているものであしからず。
なるべく何故良いかの説明もしていきます。
目次
- useFormをラップしてタイプセーフにする
- React Hook Formへの依存するコンポーネントを分ける
- 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 };
useForm
の defaultValues
は Partial<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
をラップして、 defaultValues
を Partial<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に依存しないコンポーネントを Input
や Select
とするなら、依存するコンポーネントはそれらのコンポーネントをラップした InputControl
や SelectControl
という名前にしています。
Input.tsx
React Hook Formに依存しない 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} />
なぜこのようなことをするのか?
これは制御コンポーネントとして Input
や Select
を使える余地を残しておくためです。
アプリケーションの全てのユーザー入力は当たり前ですが、React Hook Formとともに実装する必要はありません。
具体的には入力項目が1つしか無い検索フォームやモード切替のためのチェックボックスなどは単純な useState
を使った制御コンポーネントとして実装したほうがシンプルです。
const [searchValue, setSearchValue] = useState('');
// InputControlではなく、Input
<Input value={searchValue}, onChange={setSearchValue} />
発想としてはマナリンクさんのものを参考にさせていただいております。
こちらの記事も参考になると思います。
Inputなどの汎用的なコンポーネントの作り方はReact Hook Formのこちらのドキュメントを参考にして作っています。
フォームコンポーネントの設計の課題感
自作で汎用コンポーネントを作るだけなら useController
ではなく register
で実装するだけでいいのではと感じています。
useControllerはもともと、Material-UIなどのvalue引数を受け取るような制御コンポーネントでの使用を想定しています。
しかしChakra-UIなどのように非制御コンポーネントとしても使えるUIコンポーネントは useController
を使わずともいきなり register
を使ってReact 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を公式サポートしていて、導入もこちらの記事を読みながら行えば簡単です。
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>
}
バリデーションの課題感
上記の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,
});
}
上記の例だと、 userId
と userName
以外のスキーマを定義したときにコンパイルエラーで落ちてほしいですが、コンパイルが通ってしまいます。
これだとtypoでバリデーションをかけ忘れてしまうという場合もありますのでフォームのタイプとバリデーションスキーマをあわせるようにすれば良くなると思っています。
これにはyupのスキーマから型を自動生成する方式が考えられますが、そのへんはまだチャレンジできていません。
何か簡単に実現できる方いればお教えいただけると幸いです。
おわりに
1年間ちょっと React Hook Formを使ってよかったことを書き出してみました。繰り返しですが、今回の例はあくまでイチ企業での取り組みであり、公式が発表するベストプラクティスではありません。
React Hook Formの非制御や制御などの思想感を明確に反映できていない可能性もありますし、もっと良い書き方もあるかもしれません。
もっとスマートなやり方あるぜ!という方はコメントや Twitter の@Yuiiitototまでご連絡いただけると幸いです。あとフロントエンドエンジニアのご応募お待ちしてます!
Discussion
この方針を強く推奨します。Yup や Zod って「バリデーション」の文脈で語られることが多いですが、実は「スキーマ構築」がコンセプトなんですよね。
じゃあスキーマとは??? という話なんですが、「型よりリッチな表現力を持つ、データ形式を定義するためのもの」だとぼくは捉えています。一番似ているのは DB のテーブル定義で、あれよりも、もう少しつよい。
少なくとも TypeScript で Web アプリケーションフロントエンドをやっていくに当たって、型には大きく2つの弱点があります。
一つは、未知のデータに型を付けるには力技に頼る必要があること。すなわち、API を叩いて返ってきた JSON どうすんの問題です。真面目にどうにかしようと思うと省力のしの字もないようなコードを書く必要があり、そんなのめんどくさいから
return response.data as DataType
するね……みたいになりがち。もう一つは、単純に表現力が足りていないこと。
id
は UUID だし、phone
は電話番号だし、oyasumiDay
はyyyyMMdd
表現の文字列(誰だよこんなテーブル定義するような案件請けてきたの……)なんだったとしても、TypeScript の型ではぜんぶstring
と書くしかありません。こうした問題は「バリデーション」という文脈に落とし込むこともできますが、実のところ各々のフィールド自体の特性に由来するものであって、それなら何かもっと情報源として根源的なところに定義されていてほしい感じがします。DDD で言うところの Value Object みたいな。なんかそういうの、ないかな……アッ!!!!!
そこでスキーマの出番、というわけです。型を最初に書いて型ファーストでやるんではなく、スキーマを最初に書いてスキーマファーストでやる。
さらに、スキーマはデータ形式の詳細な仕様(よりリッチな型)として機能するだけでなく、「未知のデータに対する検証とパース」を行えるので、型安全よりもつよい「スキーマ安全」なデータを提供することができます。Yup なら
.validateSync
、Zod なら.parse
とか。考えてみれば API レスポンスも、ユーザーのフォーム入力値も、どちらも「未知のデータ」です。なので、スキーマをフォームバリデーションのためだけに使うのはもったいない。レスポンスデータもフォーム入力値もバグレポートも睡眠時間も、とりあえずスキーマにぶち込んでおけば大体なんかうまくいくんです。
スキーマファースト、おすすめです。
余談ですが、本格的にスキーマファーストでやっていこうとする場合、Yup より Zod のほうが向いています。Zod はオブジェクト型の構築済みスキーマから
.shape
でフィールドスキーマを取り出し、再加工や定義値の参照ができるからです。取り出せることの何が素晴らしいかというと「意味的に同じ項目だけど、場所によって入力ルールが違う」という状況に対応しやすくなります。例えば、ユーザープロフィールの編集で「パスワード欄になんか入ってたときだけパスワードを更新する」みたいなの、たまにありますよね。そういうとき Zod なら、
みたいなことができます。たすかる!
他にも、スキーマのルール値(min / max とか)を後から参照することもできるので、
みたいなことができたり。夢が広がりますね!
Ernest様
ご丁寧なお返事ありがとうございます!非常に参考になりました。
やはり最先端はスキーマファーストなフォーム開発なのですね。スキーマについてはtsとも相性がいいzodとも注目していましたが、やはりそうなのですね。
ありがとうございます。また時間があるときにこの辺は調査してみたいなと思いました!
当然のコメント失礼します。
一点小さなことだとは思うのですが質問があります。
こちらでpropsを直接渡さず、スプレッドして渡している意図はpropsがオブジェクトであることのわかりやすさ?のためでしょうか。そのまま渡してしまっても良い気がしました。
ZAKIMAZ様
ご指摘ありがとうございます。おっしゃるとおりなので修正しておきました!
こちらの記事とても参考になりました。ありがとうございます。
手元で
useDefaultForm
を書いて試したところ、以下のコンパイルエラーとなりました。エラーメッセージの通り
extends FieldValues
を付与することで解決しました。執筆当時からのTSやreact-hook-formのバージョンアップデートで状況が変わったのかな?と思いますが報告いたします。
ありがとうございます!たしかに型周りは結構変更入っているので折を見てアップデートします!