Sat Jun 21 2025
最近、Reactでフォームを弄ることが増えてきたのですが、バリデーションの仕様や、フォーム自体の仕様がよくわかっていないので調べることにしました。
これ、実際の挙動がどうなっているかわからなかったので調べてみました。
export const InputStringTestSchema = z.object({
string: z.string(),
string_optional: z.string().optional(),
string_nullable: z.string().nullable(),
string_nullish: z.string().nullish(),
string_nonempty: z.string().nonempty(),
string_nonempty_optional: z.string().nonempty().optional(),
string_nonempty_nullable: z.string().nonempty().nullable(),
string_nonempty_nullish: z.string().nonempty().nullish()
})
export type InputStringTest = z.infer<typeof InputStringTestSchema>
まず、このような感じのZodのスキーマを定義します。
定義 | 空文字 | null | undefined |
---|---|---|---|
z.string() | ✔ | ||
z.string().optional() | ✔ | ✔ | |
z.string().nullable() | ✔ | ✔ | |
z.string().nullish() | ✔ | ✔ | ✔ |
z.string().nonempty() | |||
z.string().nonempty().optional() | ✔ | ||
z.string().nonempty().nullable() | ✔ | ||
z.string().nonempty().nullish() | ✔ | ✔ |
これらがどのような値を許容するかということですが、ざっくりと表にすると上のような感じです。
それに対してReactのコンポーネント内でuseForm
を呼び出してデフォルトの状態で何が入っているかを確認してみます。
const form = useForm<InputTest>({
resolver: zodResolver(InputTestSchema),
defaultValues: {}
})
何の値が入っているかはgetValues
で取得できるので、それで確認してみたところ、すべての値がundefined
になっていました。
defalutValues
は如何なるプロパティでもundefined
を受け付けるようになっているので、undefined
は許容しないz.string()
のプロパティに初期値としてundefined
が代入できてしまう、という違和感が生じます。
といっても、これは仕方がないことで一番丸く収まるのがこの方法だと思います。
少なくとも、フォームで何かを入力させるのであればnullable
は指定する必要がないと思います。というのも、useForm
ではデフォルトがundefined
になっているからです。
Zodではバリデーションを満たさなかった場合に、カスタムでエラーメッセージを出せます。
z.string().nonempty({ message: '一文字以上入力してください' })
と書けばnonempty
のルールに違反した場合にmessage
の内容が表示されます。
で、ここで気になるのはundefined
だった場合のエラーはどこに書くのか、ということです。optional
のルールはあるけれどnonoptional
はないわけですから。
z.string({ required_error: 'String is required' })
その場合はstring()
やnumber()
などのプリミティブのルールに対してrequired_error
を追加します。これで、そのプロパティがundefined
の場合のエラーメッセージをカスタマイズできます。ちなみに、何も変更しないとoptional
がついていないにも関わらずundefined
なプロパティにはRequiredとだけ出力されます。
フォームの入力値としてどのようなユースケースがあるかを考える必要があるので、実際にUIコンポーネントと連携した場合の挙動について調査します。UIコンポーネントはShadcnを利用しますが、公式ドキュメントによるとForm
と連携できるコンポーネントは、
のようです。
実際には入力必須かそうでないかの二択になることが多いでしょう。入力必須ではあるけれど、空文字を許可するというのはレアケースだと思います。
useForm
は指定しなければ初期値としてundefined
が入っていて、一度入力して消したときに空文字が入るという仕様から、
z.string().nonempty()
とすれば、undefined
でも空文字ないことが保証されます。入力必須のパラメータにはこれを設定しましょう。
では入力が必須でないパラメータはどうすればいいかというと、z.string().optional()
でオッケーです。nonempty
をつけてしまうと、入力値を一回削除したときにバリデーションが通らなくなります。
しかし、よく考えるとこの仕様は若干気持ち悪いです。入力が必須な場合には問題ありませんが、そうでない場合にz.string().optional()
を設定すると、
undefined
が値に入っており、送信されるという、見た目上は同じに見えるのに、送信されるデータが異なるという状況が発生します。フロント側は最悪それでも良いですが、APIと連携するときに困ります。API側がz.string().nonempty().optional()
のようなundefined
でもいいけれど空文字は許容しないというバリデーションをつけている場合が往々にしてあるからです。
であれば、このZodの定義はフロント側は通るけれどバックエンドで通らないということになってしまいます。
フロント | z.string().nonempty() | z.string().optional() |
---|---|---|
z.string() | ✔ 空文字 | ✘ undefined |
z.string().optional() | ✔ undefined | ✔ |
z.string().nullable() | ✔ null | ✘ undefined |
z.string().nullish() | ✔ undefined/null | ✔ |
z.string().nonempty() | ✔ | ✘ undefined, 空文字 |
z.string().nonempty().optional() | ✔ undefined | ✘ 空文字 |
z.string().nonempty().nullable() | ✔ null | ✘ 空文字 |
z.string().nonempty().nullish() | ✔ undefined/null | ✘ 空文字 |
今回、フロント側ではz.string().nonempty()
とz.string().optional()
を使う想定なのでそれらだけ列挙しています。対して、バックエンド側はどのバリデーションかはわからないので全パターン用意します。
z.string().nonempty()
は最も厳しい制約を課しているので、バックエンドがどのような値を受け付ける状況であったとしても問題ありません。ただし、多くの場合において条件が厳しすぎる可能性があります。
例えば、バックエンドが空文字を許容したりnull
を送るべき場合にz.string().nonempty()
を設定すると厳しすぎて何も送れなくなってしまいます。
逆にz.string().optional()
は制限がなさすぎて特定の条件下でバックエンドとの不整合が発生します。特に困るのはz.string().nonempty().optional()
のようなnonempty
属性を付けている場合で、ShadcnのInputのUIコンポーネントが見た目上空っぽならundefined
か空文字を送信するという仕様になっているので、バグを生みやすくなっています。
<Input
required={required}
disabled={disabled}
placeholder={placeholder}
{...field}
{...props}
onChange={(e) => {
const value = e.target.value
field.onChange(value === '' ? undefined : value)
}}
/>
その場合にはこのようにonChange
を制御して空文字の場合にundefined
に変更するみたいな気配りが必要になります。
これだと常にundefined
になってしまうのでnull
にするような設定もできるようにPropsを変更すると良いと思います。どちらにせよ、空っぽのときに空文字を送信するというのは避けたほうがいいと思います。
チェックボックスはフロント側では選択されているものを配列として扱うことが多いです。
{
abilities: {
next: true,
nuxt: true,
react: false,
vue: true
}
}
つまりこういう定義にしてしまうと扱うのがめちゃくちゃめんどくさくなります。
{
abilities: []
}
こっちのほうが使いやすく、Zodのスキーマは、
const abilities = z.array(z.enum(['next', 'nuxt', 'react', 'vue'])).nonempty()
とすればいいです。ここの考え方は文字列のときと同じで、デフォルトではundefined
が入っていますが、どれかチェックを入れてから外すと空配列になります。
よって、使うべきチェックボックスの定義は、一つ以上の入力を必須とするのであれば、
z.array(FrameworkEnum).nonempty()
であり、オプションであるならば、
z.array(FrameworkEnum).optional()
となります。また、デフォルトでは初期値がundefined
であるため、z.array(FrameworkEnum).nonempty()
をそのまま書くとRequired
のエラーが発生しますが、これは初期値を[]
にすることで回避できます。
また、required_error
を変更して、
z.array(FrameworkEnum, { required_error: 'Array must contain at least 1 element(s)' }).nonempty()
このように定義しても良いかもしれません。また、余談ですがZodの定義の時点でデフォルト値を設定することはできません。
z.array(FrameworkEnum, { required_error: 'Array must contain at least 1 element(s)' }).nonempty().default([])
なぜならこの定義は空配列でないことを保証するnonempty
とdefault([])
が矛盾して設定になるからです。個人的にはrequired_error
のメッセージ内容を無理やり変更してしまうのは良くないと感じているので、入力が必須なのであれば初期値は[]
を入れるようにするべきかなと思います。それか、少なくともnonempty()
と同じエラーメッセージにはならないようにすべきでしょう。
うーん、ますますnullable
はどこで使うのかわからなくなってきますね......
入力値にはCheckboxと同じものを使っていますが、やはり初期値はundefined
なのですが一つも選択していない場合でもArray must contain at least 1 element(s)
のエラーメッセージが表示されました。Checkboxの場合はここがRequiredだったのでRadio Groupは必ず一つが入力されることが想定されているようです。
なので仕様上はoptional
をつけることはできますが、付ける意味はないと思います。
これも複数の選択肢から一つを選ばせるものですが、Groupboxと違い配列ではなく単一の値が一つだけ返るので注意が必要です。
また、その時のエラーメッセージはRequired
になります。
Booleanなのでtrueかfalseしかとりません。が、初期値はundefined
になっているのがクセです。
よって、全く操作していない=undefined
であるならfalseであると認識させる必要があります。
もしくはz.boolean().default(false)
と設定するのも良いです。この設定、一体何に使うのかずっと謎だったのですがundefined
が入っているときにこの値が代わりに入るみたいです。
よってz.boolean().default(false)
としておけば......
これやったらそもそもuseForm
のcontrolの型が不一致になったので多分やらないほうがいいです。
となると、defaultValues
でtrue/false
を指定しておくのが多分無難だと思いますが、オブジェクトが巨大になると設定がめんどくさそうだなとも思いました。
日付を選択するカレンダー的なやつです。
やってみたところ、返り値はstring
ではなくdate
なので、
z.date()
またはz.date().optional()
で問題ないと思います。
必要であれば例えば現在より一週間後だけ許容するみたいなバリデーションは独自で書いていいと思います。
Shadcnには入力可能なDatePickerと、選択しかできないDatePickerがあるっぽいですが、個人的には選択だけできればいいかなと思っています。
通常の用途として使うべきZodの定義と、どのShadcnのどのUIコンポーネントに利用できるかの表が以下になります。
定義 | Error Message | Input/Textarea | Checkbox | Radiobox | Select | Switch | DatePicker |
---|---|---|---|---|---|---|---|
z.string().nonempty() | Required | ✔ | ✔ | ||||
z.string().optional() | ✔ | ✔ | |||||
z.array(ZEnum).nonempty() | Array must contain at least 1 element(s) | ✔ | ✔ | ||||
z.array(ZEnum).optional() | ✔ | ✔ | |||||
z.boolean() | ✔ | ||||||
z.date() | Required | ✔ | |||||
z.date().optional() | ✔ |
今回のような使い方の場合にはoptional()
をつけたプロパティについてはエラーが発生し得ないので、エラーメッセージの部分は空にしてあります。ChatGPTとかにもきいてみましたが、やはりz.boolean()
はundefined
を許容しないのでdefaultValues
でちゃんと設定して置いたほうが良いです。
もし、defaultValues
でそれら以外の値も設定するなら文字列も空文字を設定して置いたほうが無難かもしれません。そうすればRequiredのエラーも出ないですし。ただ、z.boolean()
はともかくとして、z.date()
でバリデーションをパスする有効な値が初期値として入っているのが違和感があります。設定し忘れしてそのまま送信ボタンを押してしまうと初期値のまま送られてしまうわけですし、入力が必須なプロパティは初期値はバリデーションが通らない値であるべきだと考えています。