概要
MUI(Material UI)のフォームコンポーネントは <input> を直接 DOM に露出しないため、react-hook-form の register() が正しく機能しない。
Controller コンポーネントを使って MUI の onChange / onBlur / value を接続するのが正しいパターン。
インストール
npm install @mui/material @emotion/react @emotion/styled
npm install react-hook-form @hookform/resolvers zod
スキーマ定義
// src/lib/schemas/profile.ts
import { z } from "zod";
export const profileSchema = z.object({
name: z.string().min(1, "名前を入力してください"),
email: z.string().email("有効なメールアドレスを入力してください"),
bio: z.string().max(200, "200 文字以内で入力してください").optional(),
});
export type ProfileInput = z.infer<typeof profileSchema>;
Controller を使ったフォーム実装
// src/components/ProfileForm.tsx
"use client";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack";
import { profileSchema, type ProfileInput } from "@/lib/schemas/profile";
export function ProfileForm() {
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ProfileInput>({
resolver: zodResolver(profileSchema),
defaultValues: { name: "", email: "", bio: "" },
});
const onSubmit = async (data: ProfileInput) => {
// TODO: API 呼び出し
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Stack spacing={2} sx={{ maxWidth: 400 }}>
<Controller
name="name"
control={control}
render={({ field }) => (
<TextField
{...field}
label="名前"
error={!!errors.name}
helperText={errors.name?.message}
fullWidth
/>
)}
/>
<Controller
name="email"
control={control}
render={({ field }) => (
<TextField
{...field}
label="メールアドレス"
type="email"
error={!!errors.email}
helperText={errors.email?.message}
fullWidth
/>
)}
/>
<Controller
name="bio"
control={control}
render={({ field }) => (
<TextField
{...field}
label="自己紹介(任意)"
multiline
rows={3}
error={!!errors.bio}
helperText={errors.bio?.message ?? "200 文字以内"}
fullWidth
/>
)}
/>
<Button
type="submit"
variant="contained"
disabled={isSubmitting}
fullWidth
>
{isSubmitting ? "送信中..." : "保存する"}
</Button>
</Stack>
</form>
);
}
register() が使えない理由
// NG: MUI の TextField は ref を input に直接渡さないため動作しない
<TextField {...register("name")} />
// OK: Controller を介して MUI の field props と接続する
<Controller
name="name"
control={control}
render={({ field }) => <TextField {...field} />}
/>
Controller の render には { field, fieldState } が渡される。
field には onChange / onBlur / value / ref / name が含まれており、MUI の props と正しく対応する。
MuiTextField に defaultValues を渡す理由
// defaultValues を省略すると value が undefined になり、
// MUI が「制御なし → 制御あり」の切り替え警告を出す
useForm<ProfileInput>({
defaultValues: { name: "", email: "", bio: "" },
});
defaultValues を設定することで MUI コンポーネントを常に controlled として扱える。
ポイント
- MUI / Radix UI / shadcn/ui のような UI ライブラリは
Controllerパターンを使う helperTextにerrors.xxx?.messageを渡すとバリデーションエラーを自然に表示できるerror={!!errors.xxx}で MUI の赤色スタイルが適用されるdefaultValuesを必ず設定し、uncontrolled → controlled の切り替えを防ぐ