MUIとZodを使った日付前後バリデーションができるDatePickerの作り方

はじめに
今回はMUIを使ったバリデーションありのDatepickerを作ります。
正直、CursorなどのAI補助を使えば一発で作れちゃうかもですが、仕組みを知るのも大切ということでまとめてみます!
仕組みを知っていれば応用もできますからね!
事前準備
React 環境の構築とコンポーネントの用意
といいつつ早速私もCursorを使いました(笑)
今回は簡単なコンポーネント作成だったので、指示をCursorに渡して、サンプルコードを書いてもらいました。4往復くらいのやりとりで下記のコンポーネントが完成します(プロンプトをしっかりすればもっと往復減らせそう)
私自身は1行もコード書きませんでした。これが新時代だ。

コードの全体
下記に今回のサンプルコードを共有しますので、同じ実装をしてみたい方はコピペしてください。
中身だけ見たい!って方はトグルは開かずにそのままお読みください〜!
Dateform.tsx
// DateForm.tsx
import React from 'react';
import { useForm, Controller, FieldValues, SubmitHandler } from 'react-hook-form';
import { z, ZodTypeAny } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Box,
Button,
Typography,
Paper,
Container,
} from '@mui/material';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
// dayjs判定(型ガード)
const isDayjsLike = (
val: unknown,
): val is { toDate: () => Date; isValid: () => boolean } => {
return (
typeof val === 'object' &&
val !== null &&
'toDate' in val &&
typeof (val as { toDate: () => Date }).toDate === 'function' &&
'isValid' in val &&
typeof (val as { isValid: () => boolean }).isValid === 'function'
);
};
// MUIのDatepickerで選択した日付データ(dayjs)をzodで使用可能な日時として変換
export const parseDayjsToDate = (val: unknown): Date | null | undefined => {
if (isDayjsLike(val) && val.isValid()) {
const date = val.toDate();
if (!Number.isNaN(date.getTime())) {
return date;
}
}
return val as null | undefined;
};
// DatePickerフィールドの定義
export type DateFieldDef = {
name: string;
label: string;
required?: boolean;
};
// props型
interface DateFormProps<T extends FieldValues> {
schema: ZodTypeAny;
defaultValues: T;
fields: DateFieldDef[];
onSubmit: SubmitHandler<T>;
submitLabel?: string;
title?: string;
}
const DateForm = <T extends FieldValues>({
schema,
defaultValues,
fields,
onSubmit,
submitLabel = '送信',
title = '日付選択フォーム',
}: DateFormProps<T>) => {
const {
control,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(schema),
defaultValues: defaultValues as any,
});
return (
<LocalizationProvider dateAdapter={AdapterDayjs}>
<Container maxWidth="sm">
<Box sx={{ mt: 4 }}>
<Paper elevation={3} sx={{ p: 4 }}>
<Typography variant="h4" component="h1" gutterBottom align="center">
{title}
</Typography>
<Box component="form" onSubmit={handleSubmit(onSubmit as any)} sx={{ mt: 3 }}>
{fields.map((field) => (
<Controller
key={field.name}
name={field.name as any}
control={control}
render={({ field: rhfField }) => (
<DatePicker
label={field.label}
value={rhfField.value}
onChange={(newValue) => rhfField.onChange(newValue)}
slotProps={{
textField: {
fullWidth: true,
error: !!errors[field.name],
helperText: (errors[field.name]?.message as string) || '',
margin: 'normal',
},
}}
/>
)}
/>
))}
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
>
{submitLabel}
</Button>
</Box>
</Paper>
</Box>
</Container>
</LocalizationProvider>
);
};
export default DateForm;
index.tsx (呼び出し下)
import React from 'react';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import DateForm, { parseDayjsToDate, DateFieldDef } from './DateForm';
import { z } from 'zod';
const theme = createTheme({
palette: {
mode: 'light',
},
});
export const SearchFormSchema = z
.object({
dateFrom: z.preprocess(
parseDayjsToDate,
z.date().optional().nullable(),
),
dateTo: z.preprocess(
parseDayjsToDate,
z.date().optional().nullable(),
),
})
.refine(
(data) => {
if (data.dateFrom && data.dateTo) {
return data.dateTo >= data.dateFrom;
}
return true;
},
{
message: '開始日以降にしてください',
path: ['dateTo'],
},
);
type SearchFormType = z.infer<typeof SearchFormSchema>;
const fields: DateFieldDef[] = [
{ name: 'dateFrom', label: '開始日' },
{ name: 'dateTo', label: '終了日' },
];
const defaultValues: SearchFormType = {
dateFrom: null,
dateTo: null,
};
function App() {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<DateForm
schema={SearchFormSchema}
defaultValues={defaultValues}
fields={fields}
onSubmit={(data) => {
console.log(data);
}}
submitLabel="検索"
title="検索フォーム"
/>
</ThemeProvider>
);
}
export default App;
ポイント
今回のポイントは下記の3点です。
こんなことができる、という引き出しを持っておくことが大切かもしれません。
- MUI Datepickerの値はdayjsとして扱われている
- .preprocessでバリデーション判定前に処理を挟むことができる(zod)
- .refineでバリデーション判定後のプロパティ同士を比較可能(zod)
MUI Datepickerの値はdayjsとして扱われている
MUI産のDatepickerは中で持ってる値がdayjsの形式になっています。
そのため入力された値に対して z.date() のようなバリデーションを実装してしまうと、そもそも「Date型じゃないですよ」というエラーで落ちてしまいます。
export const SearchFormSchema = z
.object({
dateFrom:
z.date().optional().nullable(), ←MUI Datepickerで入力した値だと全てエラーになる
})
ですので入力された値をZodのバリデーションが効く形に変換(dayjs → Date型)しなくてはなりません。
.preprocessでバリデーション判定前に処理を挟むことができる
上記で型の変換をしなければいけないことがわかりました。
では実際にどのように実装するか。.preprocessを使用します。
第一引数に関数を、第二引数にzodで適応したいルールを渡します。
export const SearchFormSchema = z
.object({
dateFrom: z.preprocess(
parseDayjsToDate, // 第一引数(ここでは関数)
z.date().optional().nullable(), // 設定したいzodのバリデーションルール
),
関数はフォームから入力されたプロパティの値を引数として受け取ることができます。上記の例で言うとdateFromの入力値が引数に入ってきます。
export const parseDayjsToDate = (val)=> {
...
};
上記のvalがフォームから入力された値です。
今回は下記のようにisDayjsLikeで本当にdayjsか確認したのち、 parseDayjsToDateで型を変更する方式を採用しました。
// dayjsかどうか判定(型ガード)
const isDayjsLike = (
val: unknown,
): val is { toDate: () => Date; isValid: () => boolean } => {
return (
typeof val === 'object' &&
val !== null &&
'toDate' in val &&
typeof (val as { toDate: () => Date }).toDate === 'function' &&
'isValid' in val &&
typeof (val as { isValid: () => boolean }).isValid === 'function'
);
};
// MUIのDatepickerで選択した日付データ(dayjs)をzodで使用可能な日時として変換
export const parseDayjsToDate = (val: unknown): Date | null | undefined => {
if (isDayjsLike(val) && val.isValid()) {
const date = val.toDate();
if (!Number.isNaN(date.getTime())) {
return date;
}
}
return val as null | undefined;
};
もしかしたらもっと良い方法があるかも。
.refineでバリデーション判定後のプロパティ同士を比較可能
最後に前後比較です。検索期間を設定する場合、終了日が開始日より先ということはあり得ません。ですので、間違った入力であることをユーザーに知らせる必要があります。
zodで監視するプロパティ同士を比較するためには、.refineを使用します。
.refineはzodでバリデーションをかけた後のフォームデータ全体を引数として受け取り、任意の処理で追加のバリデーション判定をすることが出来ます。
第一引数には任意の処理を、第二引数にはバリデーション設定のオプションとなるオブジェクトを渡します。
.refine(
(data) => {
if (data.dateFrom && data.dateTo) {
return data.dateTo >= data.dateFrom;
}
return true;
}, // 第一引数(関数)
{
message: '開始日以降にしてください',
path: ['dateTo'],
}, // 第二引数(バリデーションオプション)
);
上記の例だと、条件式を元にdateToプロパティのバリデーションとして第一引数の条件が適用されます。ちなみに.refineはz.objectに連結して書きます。
export const SearchFormSchema = z
.object({....})
.refine(...);
実際の様子
一見複雑そうな実装に見えますが、噛み砕くとやっていることは実にシンプルです!
さいごに
今回はMUIとZodを使った日付前後バリデーションができるDatePickerの作り方をご紹介しました!Datepickerは自分と作ると大変だけど、ライブラリ使うと結構制約があったりして難儀なコンポーネントです。
ですが最近はAIの発展もあり複雑なコンポーネントも楽々書ける時代になりました。これからもAIにたくさん教えてもらいながら、自分の技術の引き出しを増やして、ご要望を満たすことのできるシステム作りをしていきたいものです。
誰かの参考になれば幸いです!!
それでは〜!