← ブログ一覧

React Hook Form のパフォーマンス最適化実践ガイド

大規模フォームの再レンダリングを抑制し、UX を改善する実装パターンを解説。制御モード・バリデーション戦略・動的フィールドの最適化まで、受託開発で即使える実務ガイド。

#React#TypeScript#パフォーマンス#Web
React Hook Form のパフォーマンス最適化実践ガイド

React Hook Form のパフォーマンス最適化実践ガイド

受託開発の現場では、複雑なフォームの実装が避けられません。100 個以上の入力項目を持つ管理画面、条件によって項目が動的に変わる申込フォーム、リアルタイムバリデーションが必要な入力画面——こうしたフォームで React Hook Form を使っていても、再レンダリングが多発し、入力が遅延するケースがよくあります。

本記事では、React Hook Form のパフォーマンスを最大化する実装パターンを、実務で即使える形で解説します。


1. パフォーマンス問題が起きる典型的なケース

1.1 大規模フォームでの再レンダリング

よくある症状

  • 1 つの項目を入力するたびに全フィールドが再レンダリング
  • 入力に 100ms 以上の遅延が発生
  • バリデーションメッセージの表示が遅い

原因

// ❌ 悪い例:全フォーム状態を watch している
function LargeForm() {
  const { register, watch } = useForm();
  const values = watch(); // 全フィールドの変更で再レンダリング

  return (
    <form>
      {fields.map((field) => (
        <Input key={field.name} {...register(field.name)} />
      ))}
      <pre>{JSON.stringify(values, null, 2)}</pre>
    </form>
  );
}

1.2 動的フィールドでの問題

// ❌ 悪い例:useFieldArray の再レンダリングが全体に波及
function DynamicFieldsForm() {
  const { control, watch } = useForm();
  const { fields, append } = useFieldArray({ control, name: "items" });
  const allValues = watch(); // 配列の変更で全体が再レンダリング

  return (
    <div>
      {fields.map((field, index) => (
        <div key={field.id}>
          <Input {...register(`items.${index}.name`)} />
        </div>
      ))}
    </div>
  );
}

1.3 リアルタイムバリデーションの負荷

// ❌ 悪い例:毎回 API を叩く重いバリデーション
const { register } = useForm({
  mode: "onChange", // 全入力で発火
  resolver: async (data) => {
    // 重い API バリデーション
    const result = await fetch("/api/validate", {
      method: "POST",
      body: JSON.stringify(data),
    });
    return result.json();
  },
});

2. 基本戦略:必要な箇所だけ監視・更新する

2.1 watch の使い分け

| メソッド | 再レンダリング | 用途 | |---------|--------------|------| | watch() | 全フィールド変更で発火 | デバッグ・プレビュー全体表示 | | watch("fieldName") | 特定フィールドのみ | 条件分岐・連動表示 | | useWatch({ control, name: "fieldName" }) | 特定フィールドのみ | コンポーネント分割時の監視 | | getValues() | 再レンダリングなし | submit 時の値取得 |

// ✅ 良い例:必要な項目だけ watch
function OptimizedForm() {
  const { register, control } = useForm();
  const email = useWatch({ control, name: "email" }); // email のみ監視

  return (
    <form>
      <Input {...register("name")} />
      <Input {...register("email")} />
      {email && <p>確認メールを {email} に送信します</p>}
    </form>
  );
}

2.2 コンポーネント分割による再レンダリング抑制

// ✅ 良い例:フィールドごとにコンポーネント分割
function EmailField({ control }: { control: Control<FormData> }) {
  const email = useWatch({ control, name: "email" });
  return (
    <div>
      <Input {...register("email")} />
      {email && <span>確認メールを送信</span>}
    </div>
  );
}

function OptimizedForm() {
  const { control, register } = useForm<FormData>();

  return (
    <form>
      <Input {...register("name")} /> {/* name 変更時は再レンダリングされない */}
      <EmailField control={control} /> {/* email 変更時のみ再レンダリング */}
    </form>
  );
}

3. バリデーション戦略の最適化

3.1 バリデーションモードの選定

| モード | 発火タイミング | 用途 | |--------|--------------|------| | onSubmit | submit 時のみ | デフォルト・最も軽量 | | onBlur | フォーカス離脱時 | ユーザー体験とパフォーマンスのバランス | | onChange | 入力のたび | リアルタイム必須(パスワード強度など) | | onTouched | 初回 blur 後は onChange | 段階的なフィードバック | | all | 全タイミング | 非推奨(重い) |

// ✅ 良い例:フィールドごとにモードを分ける
const { register } = useForm({
  mode: "onBlur", // デフォルトは blur 時
});

<Input
  {...register("email", {
    required: "メールアドレスは必須です",
  })}
/>;

<Input
  {...register("password", {
    onChange: (e) => {
      // パスワードだけリアルタイムチェック
      checkPasswordStrength(e.target.value);
    },
  })}
/>;

3.2 非同期バリデーションのデバウンス

import { useDebouncedCallback } from "use-debounce";

// ✅ 良い例:API バリデーションをデバウンス
function AsyncValidationField() {
  const { register, setError, clearErrors } = useForm();

  const validateUsername = useDebouncedCallback(async (value: string) => {
    if (!value) return;

    const available = await fetch(`/api/check-username?name=${value}`).then(
      (r) => r.json()
    );

    if (!available) {
      setError("username", {
        type: "manual",
        message: "このユーザー名は既に使用されています",
      });
    } else {
      clearErrors("username");
    }
  }, 500); // 500ms のデバウンス

  return (
    <Input
      {...register("username", {
        required: "ユーザー名は必須です",
        onChange: (e) => validateUsername(e.target.value),
      })}
    />
  );
}

3.3 Zod と組み合わせた段階的バリデーション

import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";

// ✅ 良い例:軽いバリデーションと重いバリデーションを分離
const baseSchema = z.object({
  email: z.string().email("メールアドレスの形式が不正です"),
  password: z.string().min(8, "8文字以上で入力してください"),
});

const asyncSchema = baseSchema.refine(
  async (data) => {
    // 重い API チェックは refine で分離
    const available = await fetch("/api/check-email", {
      method: "POST",
      body: JSON.stringify({ email: data.email }),
    }).then((r) => r.json());
    return available;
  },
  { message: "このメールアドレスは既に登録されています", path: ["email"] }
);

const { register, handleSubmit } = useForm({
  mode: "onBlur", // blur 時は軽い baseSchema のみ
  resolver: zodResolver(baseSchema),
});

const onSubmit = async (data: FormData) => {
  // submit 時だけ重い asyncSchema を実行
  const result = await asyncSchema.safeParseAsync(data);
  if (!result.success) {
    // エラーハンドリング
  }
};

4. useFieldArray の最適化

4.1 key の適切な管理

// ✅ 良い例:field.id を key に使う
function DynamicForm() {
  const { control, register } = useForm();
  const { fields, append, remove } = useFieldArray({
    control,
    name: "items",
  });

  return (
    <div>
      {fields.map((field, index) => (
        <div key={field.id}> {/* ⚠️ index ではなく field.id */}
          <Input {...register(`items.${index}.name`)} />
          <button type="button" onClick={() => remove(index)}>
            削除
          </button>
        </div>
      ))}
      <button type="button" onClick={() => append({ name: "" })}>
        追加
      </button>
    </div>
  );
}

4.2 部分的な更新

// ✅ 良い例:特定の要素だけ更新
function PartialUpdateForm() {
  const { control, register } = useForm();
  const { fields, update } = useFieldArray({ control, name: "items" });

  const handleToggle = (index: number) => {
    const item = fields[index];
    update(index, { ...item, checked: !item.checked }); // 該当インデックスのみ更新
  };

  return (
    <div>
      {fields.map((field, index) => (
        <div key={field.id}>
          <Input {...register(`items.${index}.name`)} />
          <button type="button" onClick={() => handleToggle(index)}>
            {field.checked ? "✓" : ""}
          </button>
        </div>
      ))}
    </div>
  );
}

5. 制御コンポーネントと非制御コンポーネントの使い分け

5.1 基本方針

| 方式 | メリット | デメリット | 用途 | |------|---------|----------|------| | 非制御(register) | 再レンダリングなし・高速 | リアルタイム表示に不向き | 通常の入力フィールド | | 制御(Controller) | 細かい制御が可能 | 再レンダリング発生 | カスタム UI ライブラリ(MUI、Ant Design) |

import { Controller } from "react-hook-form";
import { TextField } from "@mui/material";

// ✅ 良い例:必要な箇所だけ Controller を使う
function MixedForm() {
  const { register, control } = useForm();

  return (
    <form>
      {/* 非制御:軽量 */}
      <input {...register("name")} />

      {/* 制御:MUI を使う箇所のみ */}
      <Controller
        name="email"
        control={control}
        render={({ field }) => <TextField {...field} label="メール" />}
      />
    </form>
  );
}

5.2 shouldUnregister によるメモリ管理

// ✅ 良い例:条件付きフィールドの登録解除
function ConditionalForm() {
  const { register, watch } = useForm({
    shouldUnregister: true, // 非表示フィールドを自動で登録解除
  });
  const showAddress = watch("needsShipping");

  return (
    <form>
      <input type="checkbox" {...register("needsShipping")} />

      {showAddress && (
        <>
          <input {...register("address")} />
          <input {...register("postalCode")} />
        </>
      )}
    </form>
  );
}

6. 実務でよく使うパフォーマンスチェックリスト

必須項目

  • [ ] watch() を全フィールド監視で使っていないか
  • [ ] バリデーションモードは onSubmit または onBlur
  • [ ] 非同期バリデーションにデバウンスを設定しているか
  • [ ] useFieldArray で field.id を key に使っているか
  • [ ] 制御コンポーネントは必要最小限か

推奨項目

  • [ ] 大規模フォームはコンポーネント分割しているか
  • [ ] useWatch で特定フィールドだけ監視しているか
  • [ ] shouldUnregister: true で不要な状態を削除しているか
  • [ ] バリデーションエラーを setError で手動設定しているか(API エラー時)
  • [ ] React.memo でフィールドコンポーネントをメモ化しているか

パフォーマンス計測

import { useEffect } from "react";

// ✅ 良い例:再レンダリング回数を計測
function PerformanceMonitor({ name }: { name: string }) {
  const renderCount = useRef(0);

  useEffect(() => {
    renderCount.current += 1;
    console.log(`${name} rendered ${renderCount.current} times`);
  });

  return null;
}

function MonitoredForm() {
  return (
    <form>
      <PerformanceMonitor name="Form" />
      <Input {...register("name")} />
    </form>
  );
}

7. トラブルシューティング

7.1 入力が遅延する

チェックポイント

  1. watch() で全フィールド監視していないか
  2. mode: "onChange" で重いバリデーションを実行していないか
  3. フィールド数が 50 個以上あるか(コンポーネント分割を検討)

対処法

// ✅ デバウンスを追加
import { useDebouncedCallback } from "use-debounce";

const debouncedSetValue = useDebouncedCallback((name, value) => {
  setValue(name, value);
}, 300);

7.2 useFieldArray の更新が反映されない

原因

  • keyindex を使っている
  • defaultValues が未設定

対処法

// ✅ 正しい実装
const { fields } = useFieldArray({
  control,
  name: "items",
  keyName: "fieldId", // デフォルトは "id"
});

{fields.map((field, index) => (
  <div key={field.fieldId}> {/* field.id ではなく field.fieldId */}
    <Input {...register(`items.${index}.name`)} />
  </div>
))}

7.3 バリデーションが発火しない

原因

  • modeonSubmit のまま
  • registerrequired に空文字を指定している

対処法

// ❌ 空文字は false と判定される
<Input {...register("name", { required: "" })} />

// ✅ 明示的なメッセージ
<Input {...register("name", { required: "名前は必須です" })} />

まとめ

React Hook Form のパフォーマンス最適化は、必要な箇所だけ監視・更新するという原則に尽きます。

今日から実践できるポイント

  1. watch() の全フィールド監視を useWatch に置き換える
  2. バリデーションモードを onBlur に変更する
  3. 非同期バリデーションにデバウンスを追加する
  4. useFieldArray で field.id を key に使う
  5. 大規模フォームはコンポーネント分割する

これらを実装するだけで、入力遅延が解消され、ユーザー体験が大きく改善します。


複雑なフォーム実装でお困りですか?
Yureate では、React Hook Form を使った高パフォーマンスなフォーム設計・実装を支援しています。大規模管理画面や動的フォームの開発実績が豊富です。お気軽にお問い合わせください。

この内容について相談する他の記事を見る