本文へスキップ
React Hook Form

Part 10 · 実務で使う道具

56 / 64

React Hook Form

項目が増えたフォームを、描き直さずに扱う

Part 5 で、制御コンポーネントと入力チェックをやりました。 そこで「項目が増えたら道具を使う」と書きました。

その道具の話です。

React Hook Form は、 フォームで毎回書くことになる面倒を まとめて引き受けてくれるライブラリです。

1 文字ごとに描き直している

まず、いままでのやり方を見ます。useState で 1 項目ずつ持つ、制御コンポーネントです。

useState で作ったフォームうまくいかない例

名前を打ちながら、右上の数字を見る

1 文字打つごとに数字が増えます。しかも増えるのは名前の欄だけではありません。フォーム全体が描き直されています

項目が 3 つならこれでも困りません。 20 項目のフォームで、1 文字ごとに 20 個の入力欄が描き直されるとどうでしょう。入力が引っかかる感覚として現れはじめます。

加えて、Part 5 で見たとおり、 エラー文の管理・いつ検査するか・送信中の制御を 自分で書くことになります。

controlled-form.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";

export function ControlledForm() {
  useTrackDemoRender();

  // 1 文字打つたびに state が変わる = このフォーム全体が描き直される
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [age, setAge] = useState("");

  return (
    <div className="flex flex-col gap-3">
      <Input
        placeholder="名前"
        aria-label="名前"
        value={name}
        onChange={(event) => setName(event.target.value)}
      />
      <Input
        placeholder="メールアドレス"
        aria-label="メールアドレス"
        value={email}
        onChange={(event) => setEmail(event.target.value)}
      />
      <Input
        placeholder="年齢"
        aria-label="年齢"
        value={age}
        onChange={(event) => setAge(event.target.value)}
      />

      <Button size="sm" disabled>
        送信
      </Button>
    </div>
  );
}

入力欄を state から切り離す

React Hook Form の中心にあるのが、この 1 行です。

<Input placeholder="名前" {...register("name")} />

valueonChange も書いていません。register が返しているのは、onChange と、「この入力欄はどれか」を React Hook Form に教えるための仕掛けです。

Part 4 でやった「state は最小限にする」の徹底版です。 画面に出すのに state が要らないなら、持たない。

rhf-form.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useState } from "react";
import { z } from "zod";

// 入力の決まりを 1 か所に書く。エラー文もここに書く
const schema = z.object({
  name: z.string().min(1, "名前を入力してください"),
  email: z.email("メールアドレスの形式が正しくありません"),
  age: z
    .number({ error: "数字を入力してください" })
    .min(18, "18 歳以上で入力してください"),
});

// 決まりから型を作る。型を別に書かなくてよい
type FormValues = z.infer<typeof schema>;

export function RhfForm() {
  useTrackDemoRender();

  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormValues>({
    resolver: zodResolver(schema),
    // 一度エラーを出した項目だけ、打つたびに再判定する
    mode: "onTouched",
  });

  const [sent, setSent] = useState<FormValues | null>(null);

  // 検査を通ったときだけ、この中が呼ばれる
  const onSubmit = handleSubmit(async (values) => {
    await new Promise((resolve) => setTimeout(resolve, 600));
    setSent(values);
  });

  return (
    <form onSubmit={onSubmit} className="flex flex-col gap-3">
      <div className="flex flex-col gap-1">
        {/* value も onChange も書かない。register が繋いでくれる */}
        <Input
          placeholder="名前"
          aria-label="名前"
          aria-invalid={errors.name !== undefined}
          aria-describedby={errors.name ? "name-error" : undefined}
          {...register("name")}
        />
        {errors.name && (
          <p id="name-error" role="alert" className="text-sm text-destructive">
            {errors.name.message}
          </p>
        )}
      </div>

      <div className="flex flex-col gap-1">
        <Input
          placeholder="メールアドレス"
          aria-label="メールアドレス"
          aria-invalid={errors.email !== undefined}
          aria-describedby={errors.email ? "email-error" : undefined}
          {...register("email")}
        />
        {errors.email && (
          <p id="email-error" role="alert" className="text-sm text-destructive">
            {errors.email.message}
          </p>
        )}
      </div>

      <div className="flex flex-col gap-1">
        <Input
          type="number"
          placeholder="年齢"
          aria-label="年齢"
          aria-invalid={errors.age !== undefined}
          aria-describedby={errors.age ? "age-error" : undefined}
          // 入力欄の値は文字列なので、数値に変換してから検査に渡す
          {...register("age", { valueAsNumber: true })}
        />
        {errors.age && (
          <p id="age-error" role="alert" className="text-sm text-destructive">
            {errors.age.message}
          </p>
        )}
      </div>

      <Button type="submit" size="sm" disabled={isSubmitting}>
        {isSubmitting ? "送信中…" : "送信"}
      </Button>

      {sent && (
        <p className="text-sm text-muted-foreground">
          送信しました: {sent.name} / {sent.email} / {sent.age} 歳
        </p>
      )}
    </form>
  );
}

入力の決まりを 1 か所に書く

検査の内容は zod で書きます。 項目ごとに、条件とエラー文を並べるだけです。

Part 5 では、この検査を if で自分で書いていました。 項目が増えるほど、条件と表示が離れていきます。 ここでは決まりが 1 か所にまとまります

type FormValues = z.infer<typeof schema>;

そして型を別に書く必要がありませんz.infer が、決まりから型を作ってくれます。

rhf-form.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useState } from "react";
import { z } from "zod";

// 入力の決まりを 1 か所に書く。エラー文もここに書く
const schema = z.object({
  name: z.string().min(1, "名前を入力してください"),
  email: z.email("メールアドレスの形式が正しくありません"),
  age: z
    .number({ error: "数字を入力してください" })
    .min(18, "18 歳以上で入力してください"),
});

// 決まりから型を作る。型を別に書かなくてよい
type FormValues = z.infer<typeof schema>;

export function RhfForm() {
  useTrackDemoRender();

  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormValues>({
    resolver: zodResolver(schema),
    // 一度エラーを出した項目だけ、打つたびに再判定する
    mode: "onTouched",
  });

  const [sent, setSent] = useState<FormValues | null>(null);

  // 検査を通ったときだけ、この中が呼ばれる
  const onSubmit = handleSubmit(async (values) => {
    await new Promise((resolve) => setTimeout(resolve, 600));
    setSent(values);
  });

  return (
    <form onSubmit={onSubmit} className="flex flex-col gap-3">
      <div className="flex flex-col gap-1">
        {/* value も onChange も書かない。register が繋いでくれる */}
        <Input
          placeholder="名前"
          aria-label="名前"
          aria-invalid={errors.name !== undefined}
          aria-describedby={errors.name ? "name-error" : undefined}
          {...register("name")}
        />
        {errors.name && (
          <p id="name-error" role="alert" className="text-sm text-destructive">
            {errors.name.message}
          </p>
        )}
      </div>

      <div className="flex flex-col gap-1">
        <Input
          placeholder="メールアドレス"
          aria-label="メールアドレス"
          aria-invalid={errors.email !== undefined}
          aria-describedby={errors.email ? "email-error" : undefined}
          {...register("email")}
        />
        {errors.email && (
          <p id="email-error" role="alert" className="text-sm text-destructive">
            {errors.email.message}
          </p>
        )}
      </div>

      <div className="flex flex-col gap-1">
        <Input
          type="number"
          placeholder="年齢"
          aria-label="年齢"
          aria-invalid={errors.age !== undefined}
          aria-describedby={errors.age ? "age-error" : undefined}
          // 入力欄の値は文字列なので、数値に変換してから検査に渡す
          {...register("age", { valueAsNumber: true })}
        />
        {errors.age && (
          <p id="age-error" role="alert" className="text-sm text-destructive">
            {errors.age.message}
          </p>
        )}
      </div>

      <Button type="submit" size="sm" disabled={isSubmitting}>
        {isSubmitting ? "送信中…" : "送信"}
      </Button>

      {sent && (
        <p className="text-sm text-muted-foreground">
          送信しました: {sent.name} / {sent.email} / {sent.age} 歳
        </p>
      )}
    </form>
  );
}

できあがり

React Hook Form + zod直した例

打っても数字が増えない。空のまま送信するとエラーが出る

試してみてください。

  • 打っても数字が増えません(エラーが出たときだけ増えます)。 なおこちらは、開いた時点で react-hook-form の準備のぶん render 2 から始まります
  • 空のまま送信すると、3 つとも同時にエラーが出ます
  • 一度エラーが出た欄は、打ち直すとその場で消えます
  • 送信中はボタンが「送信中…」になり、押せなくなります

3 つめ(打ち直すと消える)は、何も書かなくても既定でそうなります。 4 つめは isSubmitting のおかげ、 最初にエラーを出す時機は mode: "onTouched" です。 Part 5 で自分で組み立てた「いつエラーを出すか」の作法が、設定 1 つで手に入っています

const onSubmit = handleSubmit(async (values) => {
  // 検査を通ったときだけ、ここが呼ばれる
});

handleSubmit が検査を済ませてから呼ぶので、この中では値が正しいことが保証されています。 (Part 5 で出てきた preventDefault も、 こちらでやってくれます)values には型も付いています。

rhf-form.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useState } from "react";
import { z } from "zod";

// 入力の決まりを 1 か所に書く。エラー文もここに書く
const schema = z.object({
  name: z.string().min(1, "名前を入力してください"),
  email: z.email("メールアドレスの形式が正しくありません"),
  age: z
    .number({ error: "数字を入力してください" })
    .min(18, "18 歳以上で入力してください"),
});

// 決まりから型を作る。型を別に書かなくてよい
type FormValues = z.infer<typeof schema>;

export function RhfForm() {
  useTrackDemoRender();

  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormValues>({
    resolver: zodResolver(schema),
    // 一度エラーを出した項目だけ、打つたびに再判定する
    mode: "onTouched",
  });

  const [sent, setSent] = useState<FormValues | null>(null);

  // 検査を通ったときだけ、この中が呼ばれる
  const onSubmit = handleSubmit(async (values) => {
    await new Promise((resolve) => setTimeout(resolve, 600));
    setSent(values);
  });

  return (
    <form onSubmit={onSubmit} className="flex flex-col gap-3">
      <div className="flex flex-col gap-1">
        {/* value も onChange も書かない。register が繋いでくれる */}
        <Input
          placeholder="名前"
          aria-label="名前"
          aria-invalid={errors.name !== undefined}
          aria-describedby={errors.name ? "name-error" : undefined}
          {...register("name")}
        />
        {errors.name && (
          <p id="name-error" role="alert" className="text-sm text-destructive">
            {errors.name.message}
          </p>
        )}
      </div>

      <div className="flex flex-col gap-1">
        <Input
          placeholder="メールアドレス"
          aria-label="メールアドレス"
          aria-invalid={errors.email !== undefined}
          aria-describedby={errors.email ? "email-error" : undefined}
          {...register("email")}
        />
        {errors.email && (
          <p id="email-error" role="alert" className="text-sm text-destructive">
            {errors.email.message}
          </p>
        )}
      </div>

      <div className="flex flex-col gap-1">
        <Input
          type="number"
          placeholder="年齢"
          aria-label="年齢"
          aria-invalid={errors.age !== undefined}
          aria-describedby={errors.age ? "age-error" : undefined}
          // 入力欄の値は文字列なので、数値に変換してから検査に渡す
          {...register("age", { valueAsNumber: true })}
        />
        {errors.age && (
          <p id="age-error" role="alert" className="text-sm text-destructive">
            {errors.age.message}
          </p>
        )}
      </div>

      <Button type="submit" size="sm" disabled={isSubmitting}>
        {isSubmitting ? "送信中…" : "送信"}
      </Button>

      {sent && (
        <p className="text-sm text-muted-foreground">
          送信しました: {sent.name} / {sent.email} / {sent.age} 歳
        </p>
      )}
    </form>
  );
}

いつ使うか

いつでも使うべき、ではありません。覚えることが増えるからです。

フォーム選ぶもの
検索窓 1 つ、チェックボックス 1 つuseState
打つたびに他の表示が変わる(文字数カウントなど)useState
項目が 3 つ以上あり、検査が要るReact Hook Form
項目の追加・削除ができる(動的なフォーム)React Hook Form

2 行目に注意してください。入力中の値を画面に出したい場合は、 結局その値を見張ることになるので、 非制御の利点が薄れます。

rhf-form.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useState } from "react";
import { z } from "zod";

// 入力の決まりを 1 か所に書く。エラー文もここに書く
const schema = z.object({
  name: z.string().min(1, "名前を入力してください"),
  email: z.email("メールアドレスの形式が正しくありません"),
  age: z
    .number({ error: "数字を入力してください" })
    .min(18, "18 歳以上で入力してください"),
});

// 決まりから型を作る。型を別に書かなくてよい
type FormValues = z.infer<typeof schema>;

export function RhfForm() {
  useTrackDemoRender();

  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormValues>({
    resolver: zodResolver(schema),
    // 一度エラーを出した項目だけ、打つたびに再判定する
    mode: "onTouched",
  });

  const [sent, setSent] = useState<FormValues | null>(null);

  // 検査を通ったときだけ、この中が呼ばれる
  const onSubmit = handleSubmit(async (values) => {
    await new Promise((resolve) => setTimeout(resolve, 600));
    setSent(values);
  });

  return (
    <form onSubmit={onSubmit} className="flex flex-col gap-3">
      <div className="flex flex-col gap-1">
        {/* value も onChange も書かない。register が繋いでくれる */}
        <Input
          placeholder="名前"
          aria-label="名前"
          aria-invalid={errors.name !== undefined}
          aria-describedby={errors.name ? "name-error" : undefined}
          {...register("name")}
        />
        {errors.name && (
          <p id="name-error" role="alert" className="text-sm text-destructive">
            {errors.name.message}
          </p>
        )}
      </div>

      <div className="flex flex-col gap-1">
        <Input
          placeholder="メールアドレス"
          aria-label="メールアドレス"
          aria-invalid={errors.email !== undefined}
          aria-describedby={errors.email ? "email-error" : undefined}
          {...register("email")}
        />
        {errors.email && (
          <p id="email-error" role="alert" className="text-sm text-destructive">
            {errors.email.message}
          </p>
        )}
      </div>

      <div className="flex flex-col gap-1">
        <Input
          type="number"
          placeholder="年齢"
          aria-label="年齢"
          aria-invalid={errors.age !== undefined}
          aria-describedby={errors.age ? "age-error" : undefined}
          // 入力欄の値は文字列なので、数値に変換してから検査に渡す
          {...register("age", { valueAsNumber: true })}
        />
        {errors.age && (
          <p id="age-error" role="alert" className="text-sm text-destructive">
            {errors.age.message}
          </p>
        )}
      </div>

      <Button type="submit" size="sm" disabled={isSubmitting}>
        {isSubmitting ? "送信中…" : "送信"}
      </Button>

      {sent && (
        <p className="text-sm text-muted-foreground">
          送信しました: {sent.name} / {sent.email} / {sent.age} 歳
        </p>
      )}
    </form>
  );
}

理解できたか確かめる

確認クイズ

React Hook Form で打っても再レンダリングが起きないのはなぜ?

確認クイズ

z.infer<typeof schema> は何をしている?

確認クイズ

検索窓 1 つのフォームに React Hook Form を使うべき?

rhf-form.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useState } from "react";
import { z } from "zod";

// 入力の決まりを 1 か所に書く。エラー文もここに書く
const schema = z.object({
  name: z.string().min(1, "名前を入力してください"),
  email: z.email("メールアドレスの形式が正しくありません"),
  age: z
    .number({ error: "数字を入力してください" })
    .min(18, "18 歳以上で入力してください"),
});

// 決まりから型を作る。型を別に書かなくてよい
type FormValues = z.infer<typeof schema>;

export function RhfForm() {
  useTrackDemoRender();

  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormValues>({
    resolver: zodResolver(schema),
    // 一度エラーを出した項目だけ、打つたびに再判定する
    mode: "onTouched",
  });

  const [sent, setSent] = useState<FormValues | null>(null);

  // 検査を通ったときだけ、この中が呼ばれる
  const onSubmit = handleSubmit(async (values) => {
    await new Promise((resolve) => setTimeout(resolve, 600));
    setSent(values);
  });

  return (
    <form onSubmit={onSubmit} className="flex flex-col gap-3">
      <div className="flex flex-col gap-1">
        {/* value も onChange も書かない。register が繋いでくれる */}
        <Input
          placeholder="名前"
          aria-label="名前"
          aria-invalid={errors.name !== undefined}
          aria-describedby={errors.name ? "name-error" : undefined}
          {...register("name")}
        />
        {errors.name && (
          <p id="name-error" role="alert" className="text-sm text-destructive">
            {errors.name.message}
          </p>
        )}
      </div>

      <div className="flex flex-col gap-1">
        <Input
          placeholder="メールアドレス"
          aria-label="メールアドレス"
          aria-invalid={errors.email !== undefined}
          aria-describedby={errors.email ? "email-error" : undefined}
          {...register("email")}
        />
        {errors.email && (
          <p id="email-error" role="alert" className="text-sm text-destructive">
            {errors.email.message}
          </p>
        )}
      </div>

      <div className="flex flex-col gap-1">
        <Input
          type="number"
          placeholder="年齢"
          aria-label="年齢"
          aria-invalid={errors.age !== undefined}
          aria-describedby={errors.age ? "age-error" : undefined}
          // 入力欄の値は文字列なので、数値に変換してから検査に渡す
          {...register("age", { valueAsNumber: true })}
        />
        {errors.age && (
          <p id="age-error" role="alert" className="text-sm text-destructive">
            {errors.age.message}
          </p>
        )}
      </div>

      <Button type="submit" size="sm" disabled={isSubmitting}>
        {isSubmitting ? "送信中…" : "送信"}
      </Button>

      {sent && (
        <p className="text-sm text-muted-foreground">
          送信しました: {sent.name} / {sent.email} / {sent.age} 歳
        </p>
      )}
    </form>
  );
}

この章のまとめ

  • 制御コンポーネントは、1 文字ごとにフォーム全体を描き直している
  • React Hook Form は値を state に持たないので、 打っても描き直されない
  • zod で決まりを 1 か所に書き、そこから型も作る
  • handleSubmit の中は、検査を通った値だけが来る
  • 小さいフォームには要らない。項目が増えて検査が要るようになってから

rhf-form.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useState } from "react";
import { z } from "zod";

// 入力の決まりを 1 か所に書く。エラー文もここに書く
const schema = z.object({
  name: z.string().min(1, "名前を入力してください"),
  email: z.email("メールアドレスの形式が正しくありません"),
  age: z
    .number({ error: "数字を入力してください" })
    .min(18, "18 歳以上で入力してください"),
});

// 決まりから型を作る。型を別に書かなくてよい
type FormValues = z.infer<typeof schema>;

export function RhfForm() {
  useTrackDemoRender();

  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormValues>({
    resolver: zodResolver(schema),
    // 一度エラーを出した項目だけ、打つたびに再判定する
    mode: "onTouched",
  });

  const [sent, setSent] = useState<FormValues | null>(null);

  // 検査を通ったときだけ、この中が呼ばれる
  const onSubmit = handleSubmit(async (values) => {
    await new Promise((resolve) => setTimeout(resolve, 600));
    setSent(values);
  });

  return (
    <form onSubmit={onSubmit} className="flex flex-col gap-3">
      <div className="flex flex-col gap-1">
        {/* value も onChange も書かない。register が繋いでくれる */}
        <Input
          placeholder="名前"
          aria-label="名前"
          aria-invalid={errors.name !== undefined}
          aria-describedby={errors.name ? "name-error" : undefined}
          {...register("name")}
        />
        {errors.name && (
          <p id="name-error" role="alert" className="text-sm text-destructive">
            {errors.name.message}
          </p>
        )}
      </div>

      <div className="flex flex-col gap-1">
        <Input
          placeholder="メールアドレス"
          aria-label="メールアドレス"
          aria-invalid={errors.email !== undefined}
          aria-describedby={errors.email ? "email-error" : undefined}
          {...register("email")}
        />
        {errors.email && (
          <p id="email-error" role="alert" className="text-sm text-destructive">
            {errors.email.message}
          </p>
        )}
      </div>

      <div className="flex flex-col gap-1">
        <Input
          type="number"
          placeholder="年齢"
          aria-label="年齢"
          aria-invalid={errors.age !== undefined}
          aria-describedby={errors.age ? "age-error" : undefined}
          // 入力欄の値は文字列なので、数値に変換してから検査に渡す
          {...register("age", { valueAsNumber: true })}
        />
        {errors.age && (
          <p id="age-error" role="alert" className="text-sm text-destructive">
            {errors.age.message}
          </p>
        )}
      </div>

      <Button type="submit" size="sm" disabled={isSubmitting}>
        {isSubmitting ? "送信中…" : "送信"}
      </Button>

      {sent && (
        <p className="text-sm text-muted-foreground">
          送信しました: {sent.name} / {sent.email} / {sent.age} 歳
        </p>
      )}
    </form>
  );
}