12 人が閲覧(直近 30 日) React

Reactで確認画面付きフォームを作る方法|入力→確認→送信と戻る・リロード対策【React Hook Form 7.91】

Reactで確認画面付きフォームを作る方法|入力→確認→送信と戻る・リロード対策【React Hook Form 7.91】

問い合わせやアンケートのフォームに「入力 → 確認 → 送信」の3ステップを入れるとき、Reactで手間がかかるのは画面の切り替えではなく、入力値をどこに置き、どの操作で失うかです。この記事では React Hook Form 7.91.0 と Zod 4.6.7 で確認画面付きフォームを組み、送信エラーの差し戻し、リロード・ブラウザバック・直アクセスへの備えまでを、実際に動かしたコードで説明します(React 19.3.1・react-router 8.5.1 で 2026年10月28日に動作確認)。

まとめ:Reactの確認画面付きフォームの要点

  • 入力値は React Hook Form の1つのフォーム状態に置く。FormProvider で配れば、確認画面は getValues() で読んで表示するだけで済みます。
  • 画面の分け方は、まず1コンポーネント内のステップ切替。URLを分けるのは、ステップごとの計測や多段ウィザードが要るときだけにします。項目が数個ならモーダル(dialog 要素)でも足ります。
  • 確認画面には送信失敗時の処理も用意する。isSubmitting で二重送信を止め、サーバーが返した項目エラーは setError で入力画面へ差し戻します。
  • リロードと直アクセスはReactの状態を消す。sessionStorage へ subscribe() で保存して reset() で復元します。v8 beta では watch(callback) が削除されています。
  • React Router の useBlocker はデータルーター専用。<BrowserRouter> の中で呼ぶと例外になります。タブを閉じる操作は beforeunload で別に扱います。

確認画面の出し方:ステップ切替・URL分割・モーダルの選び方

確認画面の実装は、最初に「どの方式で出すか」を決めると残りが決まります。方式ごとに、値の受け渡しとリロード時の挙動が変わります。

方式 URL 値の受け渡し リロードしたとき 向く場面
ステップ切替 1つ 不要(同じ状態) 入力画面に戻る 問い合わせ・資料請求
URL分割 入力・確認・完了 親ルートか sessionStorage 復元か差し戻し 多段ウィザード・画面別計測
モーダル(dialog) 1つ 不要(同じ状態) 入力画面のまま 項目が少ないフォーム

迷ったらステップ切替にします。URLを /contact/confirm に分けた時点で、確認ページへの直アクセス、確認ページでのリロード、アプリ内の別ページへの移動という3つの分岐を自分で書くことになるからです。URL分割を選ぶのは、画面別の計測、多段の入力、ステップへの直接リンクのように、URLごとに管理したい要件があるときです。

モーダルは入力欄が背面に残るので、5項目程度の短いフォームなら確認画面より軽く済みます。長い問い合わせ本文を確認させる用途には向きません。モーダルの中でスクロールが発生し、確認画面より読みにくくなります。入力欄を見れば内容を確かめられる短いフォームなら、確認画面を省き、送信ボタンの横に小さな確認(Popover)を出すだけでも足ります。

React Hook Form と Zod による入力・確認・送信の実装

ここからはステップ切替で組みます。必要なパッケージは3つです。記事のコードは react-hook-form 7.91.0・zod 4.6.7・@hookform/resolvers 6.0.1 で動作確認しています。@hookform/resolvers は同じ2026年10月28日に 6.0.2 が公開されましたが、peerDependencies は react-hook-form ^7.55.0・zod ^3.25.0 || ^4.0.0 で、この組み合わせはその範囲に収まります。記事のコードを同じ条件で再現するなら、版を指定して入れます。

npm i [email protected] [email protected] @hookform/[email protected]

Zod スキーマによる検証ルールの一元化

検証ルールをスキーマに切り出すと、サーバー側でも共有できます。ブラウザ側の検証は迂回できるため、受付APIでも受信データを検証し、不正な値を保存しないようにします。z.email() は Zod 4 のトップレベルの書き方です。Zod の書き方やカスタム検証はReactのZodフォームバリデーション実装|zodResolverの導入とrefine・Zod 4の落とし穴で扱っています。

// schema.ts
import { z } from "zod";

export const contactSchema = z.object({
  name: z.string().min(1, "お名前は必須です"),
  email: z.email("メールアドレスの形式で入力してください"),
  message: z.string().min(10, "10文字以上で入力してください"),
});
export type FormValues = z.infer<typeof contactSchema>;

FormProvider による入力画面と確認画面の状態共有

FormProvider は React の Context でフォームの状態を配下に配ります。入力画面も確認画面も props でフォームの値を受け取らず、useFormContext() で同じ状態を読みます。画面の切り替えは step という1つの状態だけで行います。

// ContactForm.tsx
import { useState } from "react";
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { contactSchema, type FormValues } from "./schema";

export function ContactForm() {
  const methods = useForm<FormValues>({
    resolver: zodResolver(contactSchema),
    defaultValues: { name: "", email: "", message: "" },
  });
  const [step, setStep] = useState<"input" | "confirm" | "done">("input");

  // 「確認画面へ」で全項目を検証し、通ったときだけ確認へ進む
  const goConfirm = methods.handleSubmit(() => setStep("confirm"));
  const submit = methods.handleSubmit(async (data) => {
    // 送信処理(後述)
  });

  return (
    <FormProvider {...methods}>
      {step === "input" && <InputStep onNext={goConfirm} />}
      {step === "confirm" && (
        <ConfirmStep onBack={() => setStep("input")} onSubmit={submit} />
      )}
      {step === "done" && <p>送信しました</p>}
    </FormProvider>
  );
}

「確認画面へ」に handleSubmit を噛ませているので、確認画面へ進む前に必ず全項目が検証されます。mode を指定しない既定値(onSubmit)でも、このボタンで全エラーが出ます。入力中に項目ごとに検証したい場合の mode の選び方はReact Hook Formとは?使い方とv7の書き方・検証タイミング【7.91対応】にまとめています。

import { useFormContext, useFormState } from "react-hook-form";

function InputStep({ onNext }: { onNext: () => void }) {
  const { register } = useFormContext<FormValues>();
  const { errors } = useFormState<FormValues>();
  return (
    <div>
      {errors.root?.server && <p role="alert">{errors.root.server.message}</p>}
      <label>お名前<input {...register("name")} /></label>
      {errors.name && <p role="alert">{errors.name.message}</p>}
      <label>メール<input {...register("email")} /></label>
      {errors.email && <p role="alert">{errors.email.message}</p>}
      <label>お問い合わせ<textarea {...register("message")} /></label>
      {errors.message && <p role="alert">{errors.message.message}</p>}
      <button type="button" onClick={onNext}>確認画面へ</button>
    </div>
  );
}

getValues による確認画面の読み取り専用表示

確認画面は表示した時点の値が出れば足りるので、再描画を購読する watch ではなく getValues() で読みます。「入力へ戻る」は step を戻すだけです。フォームの状態は FormProvider 側に残っているので、入力画面に戻ると値はそのまま入っています。

import { useEffect, useRef } from "react";
import { useFormContext, useFormState } from "react-hook-form";

function ConfirmStep({ onBack, onSubmit }: {
  onBack: () => void; onSubmit: () => void;
}) {
  const { getValues } = useFormContext<FormValues>();
  const { isSubmitting } = useFormState<FormValues>();
  const headingRef = useRef<HTMLHeadingElement>(null);

  // 画面が切り替わったことを支援技術に伝えるため、見出しへフォーカスを移す
  useEffect(() => { headingRef.current?.focus(); }, []);

  const v = getValues();
  return (
    <div>
      <h2 tabIndex={-1} ref={headingRef}>入力内容の確認</h2>
      <dl>
        <dt>お名前</dt><dd>{v.name}</dd>
        <dt>メール</dt><dd>{v.email}</dd>
        <dt>お問い合わせ</dt>
        <dd style={{ whiteSpace: "pre-wrap" }}>{v.message}</dd>
      </dl>
      <button type="button" onClick={onBack} disabled={isSubmitting}>入力へ戻る</button>
      <button type="button" onClick={onSubmit} disabled={isSubmitting}>
        {isSubmitting ? "送信中…" : "送信する"}
      </button>
    </div>
  );
}

見出しに tabIndex={-1} を付けてフォーカスを移しているのは、ステップ切替ではURLもページタイトルも変わらず、スクリーンリーダーの利用者に画面が変わったことが伝わらないためです。tabIndex={-1} が無い h2 は focus() を呼んでもフォーカスを受け取りません。読み上げ順の設計はWebアクセシビリティのスクリーンリーダー対応:NVDA・VoiceOverでの実機検証と読み上げ順序の設計で扱っています。

確認画面の送信エラー処理と二重送信対策

この記事のステップ切替例では、サーバーと通信するのは確認画面の「送信する」だけです。入力画面の検証を通っても、メールアドレスの重複や受付期間の終了のように、サーバーでしか分からない理由で送信は失敗します。

isSubmitting による送信中のボタン無効化

handleSubmit に async 関数を渡すと、その Promise が終わるまで formState.isSubmitting が true になります。前述の ConfirmStep はこれで「送信する」と「入力へ戻る」を無効にしています。jsdom で検証したところ、送信中にもう一度クリックしても送信関数の呼び出しは1回のままでした。ボタンの無効化は画面側の対策なので、通信の再送で同じ申し込みが2件入るのを防ぐには、サーバー側でも受付IDなどで重複を弾きます。

setError によるサーバーエラー表示と入力画面への差し戻し

送信が失敗したら入力画面に戻し、どの項目を直せばよいかを表示します。項目に紐づくエラーは setError("email", …)、通信障害のように項目に紐づかないエラーは setError("root.server", …) に入れます。

const FIELDS = ["name", "email", "message"] as const;

const submit = methods.handleSubmit(async (data) => {
  const res = await fetch("/api/contact", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(data),
  }).catch(() => null); // 通信そのものの失敗は null にする
  if (res?.ok) {
    methods.reset({ name: "", email: "", message: "" });
    clearDraft(); // reset による保存通知の後で一時保存を消す
    setStep("done");
    return;
  }
  // 例: { "field": "email", "message": "このメールアドレスは登録済みです" }
  const body = await res?.json().catch(() => null);
  setStep("input");
  if (body && FIELDS.includes(body.field) && typeof body.message === "string") {
    methods.setError(body.field, { type: "server", message: body.message });
  } else {
    methods.setError("root.server", {
      message: "送信に失敗しました。時間をおいて再度お試しください",
    });
  }
});

fetch は通信そのものが失敗すると例外を投げるので、.catch(() => null) で受けて root.server に流しています。成功時に reset() で空に戻すのは、完了後にブラウザの戻るで入力画面へ戻ったとき、送信済みの値が残って同じ内容を再送できる状態を作らないためです。reset() による保存の通知の後で clearDraft() を呼べば、一時保存も残りません(ヘッドレスの Chromium で、完了から戻ると入力欄が空で、保存キーも消えていることを確認)。応答の JSON も、表示している項目名と文字列のメッセージがそろっているときだけ項目エラーにします。ここで setError の第3引数に { shouldFocus: true } を渡しても、エラーの項目にフォーカスは移りませんでした(jsdom で確認)。setStep("input") の直後はまだ入力欄が描画されていないためです。フォーカスは入力画面が表示された後に、入力画面の側で移します。

// InputStep の中に追加する
const { register, setFocus, getFieldState } = useFormContext<FormValues>();

useEffect(() => {
  const first = FIELDS.find((n) => getFieldState(n).error);
  if (first) setFocus(first);
}, [getFieldState, setFocus]);

一度送信を試みた後は、reValidateMode の既定値 onChange で項目ごとに検証し直されるので、項目のエラーは入力を直した時点で消えます(ヘッドレスの Chromium で、メールアドレスを書き換えた直後にエラー表示が消えることを確認)。root.server は次に「確認画面へ」を押したときの handleSubmit で消えます。どちらも手で clearErrors を呼ぶ必要はありません。

リロード・直アクセス・ブラウザバック時の入力値保持

ここまでの構成なら、確認画面の「入力へ戻る」で値は消えません。消えるおそれがあるのは、ページのリロード、確認ページへの直アクセス、ブラウザの戻るでフォームの外へ出る操作です。リロードと直アクセスでは React の状態が初期化されます。ブラウザの戻るでは bfcache によってページごと保持される場合もありますが、保持される保証はありません。保持されない場合に備え、ブラウザ側に一時保存して復元します。

subscribe と reset による sessionStorage への保存・復元

値が変わるたびに sessionStorage へ書き、マウント時に reset() で戻すカスタムフックです。InputStep の中で呼ぶと、確認から戻るたびに復元が走り、差し戻したサーバーエラーまで消えます。ステップ切替の外側、ContactForm の FormProvider 直下に常に置きます。

import { useEffect } from "react";
import { useFormContext } from "react-hook-form";

const KEY = "contact-form";

export function usePersistForm() {
  const { subscribe, reset } = useFormContext<FormValues>();

  // 復元(マウント時に1回)
  useEffect(() => {
    try {
      const saved = sessionStorage.getItem(KEY);
      // keepDefaultValues で既定値(空欄)を残し、復元後も isDirty を true にする
      if (saved) {
        const draft = JSON.parse(saved);
        if (draft && typeof draft.name === "string" &&
            typeof draft.email === "string" && typeof draft.message === "string") {
          reset({ name: draft.name, email: draft.email, message: draft.message },
            { keepDefaultValues: true });
        }
      }
    } catch {
      // 壊れた値や保存できない環境では、空のフォームから始める
    }
  }, [reset]);

  // 保存(値が変わるたび)。subscribe は購読解除の関数を返す
  useEffect(() => {
    return subscribe({
      formState: { values: true },
      callback: ({ values }) => {
        try {
          sessionStorage.setItem(KEY, JSON.stringify(values));
        } catch {
          // 容量超過などで保存できなくても、入力は続けられるようにする
        }
      },
    });
  }, [subscribe]);
}

export function clearDraft() {
  try {
    sessionStorage.removeItem(KEY);
  } catch {
    // 保存できない環境では消すものも無い
  }
}

// ContactForm の FormProvider 直下に <Persist /> として常に置く
export function Persist() {
  usePersistForm();
  return null;
}

復元の reset() に keepDefaultValues: true を付けないと、復元した値が既定値として扱われ、入力途中なのに isDirty が false になります。後述の離脱確認が isDirty を見るので、付けておきます(付けた版で復元後の isDirty が true になることを確認)。復元の前に3項目が文字列かを確かめているのは、型の崩れた保存値をそのまま入れると、入力欄に「[object Object]」のような値が出るためです。送信用の contactSchema で検証すると入力途中の下書きを復元できなくなるので、ここでは型だけを見ます。保存も復元も削除も try で囲んでいるのは、保存が拒否される環境では sessionStorage の呼び出し自体が例外を投げるためです。3つの操作が例外を投げる状態にしても、送信して完了画面まで進めました。

保存に watch(callback) を使う例がまだ多く見つかりますが、v7 の公式ドキュメントでもこの使い方は非推奨で、subscribe への移行が案内されています。さらに v8.0.0-beta.5 のリリースノートに「Remove the watch(callback) API; use subscribe() instead」とあります。subscribe は 7.55.0 で追加されたメソッドで(7.54.2 の型定義には無い)、7.91.0 でも v8 でも同じ書き方で動きます。

このままだと、メニューから新しくフォームを開いたときにも前回の入力が戻ります。それが困る場合は、送信完了時の clearDraft() に加え、確認画面から戻ったときだけ復元するよう、復元の条件に遷移元を加えます。

ブラウザバックと入力・確認ステップの同期

ステップ切替のままブラウザの戻るボタンを押すと、確認画面ではなくフォームの前のページへ戻ってしまいます。確認画面へ進むときに履歴を1件積み、popstate で履歴の印(step)を読んで画面を合わせます。購読は確認画面ではなく常に表示されている ContactForm 側に置きます。確認画面の中に置くと、確認から戻った後の「進む」を受け取れず、履歴は確認なのに画面は入力のままになります。useEffectEvent は React 19.2(2025年10月1日)で追加されたフックで、最新の setStep や canConfirm を effect の依存配列に入れずに呼べます。

import { useEffect, useEffectEvent } from "react";

type Step = "input" | "confirm" | "done";

// ContactForm の中で呼ぶ(入力・確認の切り替えより外側に置く)
function useStepHistory(setStep: (s: Step) => void, canConfirm: () => boolean) {
  const sync = useEffectEvent((e: PopStateEvent) => {
    if (e.state?.step === "done") {
      setStep("done");
      return;
    }
    if (e.state?.step === "confirm" && canConfirm()) {
      setStep("confirm");
      return;
    }
    // 確認へ戻れないときは、この履歴を「入力」に書き換えて表示と揃える
    window.history.replaceState({ ...window.history.state, step: "input" }, "");
    setStep("input");
  });

  useEffect(() => {
    // 今の履歴に「入力」の印を付ける(URL は変えない)
    window.history.replaceState({ ...window.history.state, step: "input" }, "");
    const onPop = (e: PopStateEvent) => sync(e);
    window.addEventListener("popstate", onPop);
    return () => window.removeEventListener("popstate", onPop);
  }, []);

  return {
    // 利用者が「確認画面へ」を押したときだけ履歴を1件積む
    pushConfirm: () => {
      window.history.pushState({ ...window.history.state, step: "confirm" }, "");
      setStep("confirm");
    },
    back: () => window.history.back(),
  };
}

ContactForm 側は、確認へ進む処理と「入力へ戻る」をこのフックの関数に差し替えます。「入力へ戻る」で setStep を直接呼ばず history.back() にするのは、履歴の現在位置を確認の1つ前(入力)へ移し、画面と位置を揃えるためです。history.back() は履歴を消さないので、ブラウザの「進む」で確認へ戻れます。

// ContactForm の差し替え部分
const canConfirm = () => contactSchema.safeParse(methods.getValues()).success;
const { pushConfirm, back } = useStepHistory(setStep, canConfirm);
const goConfirm = methods.handleSubmit(pushConfirm);
// ConfirmStep の onBack に back を渡す。送信処理は setStep("input") を back() に変え、
// 成功時は setStep("done") の前に次の1行を足す
window.history.replaceState({ ...window.history.state, step: "done" }, "");

「進む」で確認の履歴に戻ったときは、その時点の値をスキーマで検証し、通らなければ入力画面のままにします。ヘッドレスの Chromium(StrictMode の開発ビルド)で、確認→戻る→進む、画面のボタンで戻る、無効な値に直してから進む、送信失敗での差し戻し、送信完了のそれぞれで、表示と history.state.step が一致することを確認しました。history.state を上書きせず広げて書いているのは、ルーターが同じ場所に自分の情報を置いている場合に消さないためです。

履歴を積むのは利用者が「確認画面へ」を押した後に限ります。ページを開いただけで pushState を重ねる実装は、Google のスパムポリシーが対象にする「戻るボタンの乗っ取り」と同じ形になります。判定基準は戻るボタンの乗っ取りとは?Googleのスパム判定基準と手動対策・点検方法で扱っています。

sessionStorage と localStorage の使い分け

保存先 リロード タブを閉じる 別タブ 確認画面での扱い
sessionStorage 残る 消える 共有しない 一時保持の既定
localStorage 残る 残る 共有する 個人情報は置かない

MDN の sessionStorage の説明では、sessionStorage はオリジンとタブの両方で区切られ、ページの再読み込みや復元では残り、タブやウィンドウを閉じると消えます。「下書きを次回も引き継ぐ」要件がない限り、氏名やメールアドレスを localStorage に置く理由はありません。共有端末では次の利用者に見えます。保存先ごとの容量と寿命はIndexedDBのデメリットと容量上限|localStorage・Cookieとの違いと使い分けで比べています。

URLを分ける構成:直アクセスの差し戻しと離脱ブロック

入力・確認・完了をそれぞれのURLにしても、useForm と FormProvider を親のレイアウトルートに置き、子ルートを <Outlet /> で切り替えれば、ページをまたいで同じフォーム状態を使えます(react-router 8.5.1 で、2ページ目から戻ったときに値が残ることを確認)。ページごとに別のフォームを作る構成なら、値は前節の sessionStorage で渡します。どちらの構成でも、再読み込みや直アクセスでは状態が消えるので、次の3つを足します。

確認ページへの直アクセス時の検証と差し戻し

確認ページを開いた時点で、保存データを前述の contactSchema で検証し、通らなければ <Navigate replace> で入力ページへ戻します。保存は入力途中の値も含むので、データがあるかどうかだけで判断すると、未検証の値のまま確認ページに入れてしまいます。jsdom では、保存なし・壊れた JSON・"null"・入力途中の値がすべて入力ページへ戻りました。replace にしておくと、戻るボタンで空の確認ページに戻りません。

import { Navigate } from "react-router";
import { contactSchema } from "./schema";

function ConfirmPage() {
  let result;
  try {
    result = contactSchema.safeParse(JSON.parse(sessionStorage.getItem(KEY) ?? "null"));
  } catch {
    return <Navigate to="/contact" replace />; // 壊れた JSON
  }
  // 保存は入力途中の値も含むので、スキーマを通らなければ入力へ戻す
  if (!result.success) return <Navigate to="/contact" replace />;
  const v = result.data;
  return <dl><dt>お名前</dt><dd>{v.name}</dd>{/* 以下略 */}</dl>;
}

useBlocker によるアプリ内の離脱確認とデータルーター要件

入力途中にヘッダーのリンクで別ページへ移動されるのを止めるには、React Router の useBlocker を使います。返り値の state が "blocked" の間に確認UIを出し、proceed() で移動、reset() で留まります。

import { useEffect, useRef } from "react";
import { useBlocker } from "react-router";
import { useFormState } from "react-hook-form";

function LeaveConfirm() {
  const ref = useRef<HTMLDialogElement>(null);
  const { isDirty } = useFormState();
  // 入力途中で、/contact とその配下以外へ移動するときだけ止める
  const blocker = useBlocker(({ nextLocation }) => {
    const p = nextLocation.pathname;
    return isDirty && !(p === "/contact" || p.startsWith("/contact/"));
  });

  useEffect(() => {
    if (blocker.state === "blocked") ref.current?.showModal();
    else ref.current?.close();
  }, [blocker.state]);

  return (
    <dialog ref={ref} onCancel={() => blocker.reset?.()} aria-labelledby="leave-title">
      <p id="leave-title">フォームから移動しますか?保存に失敗している場合、入力内容は復元できません。</p>
      <button onClick={() => blocker.reset?.()}>入力を続ける</button>
      <button onClick={() => blocker.proceed?.()}>移動する</button>
    </dialog>
  );
}

確認UIにも dialog 要素を使うと、開いたときにフォーカスが「入力を続ける」へ移り、閉じたときは移動しようとしたリンクへ戻ります(ヘッドレスの Chromium で5回とも同じ結果)。useBlocker は createBrowserRouter などで作るデータルーター(data mode)か framework mode でしか動きません。react-router 8.5.1 で <MemoryRouter> の中から呼ぶと「useBlocker must be used within a data router.」という例外になりました。<BrowserRouter> で組んだ既存アプリに足すなら、ルーターの作り方から変える必要があります。

beforeunload によるタブ終了・リロード時の離脱確認

useBlocker はリロードや別サイトへの移動を扱いません。そちらはブラウザ標準の beforeunload で、入力途中(isDirty)のときだけ登録します。isDirty は送信が済んだかどうかを表さないので、このフックは入力・確認ステップの間だけ動くコンポーネントで呼び、完了画面ではマウントしないようにします(例:{step !== "done" && <LeaveGuard />})。

import { useEffect } from "react";
import { useFormState } from "react-hook-form";

function useLeaveGuard() {
  const { isDirty } = useFormState();
  useEffect(() => {
    if (!isDirty) return;
    const handler = (e: BeforeUnloadEvent) => {
      e.preventDefault();
      e.returnValue = true; // 旧仕様のブラウザ向け(表示文言には使われない)
    };
    window.addEventListener("beforeunload", handler);
    return () => window.removeEventListener("beforeunload", handler);
  }, [isDirty]);
}

MDN の beforeunload の説明では、現行のブラウザはページに一度も操作が無い(スティッキーアクティベーションが無い)とダイアログを出さず、文言もブラウザ固定でページ側から変えられません。未保存の入力が無くなったらリスナーを外すよう勧めているので、上のコードも isDirty が false の間は登録しません。確認ダイアログを出すかどうかは、入力し直す負担と、毎回ダイアログに答える負担を比べて決めます。項目が数個の問い合わせなら入れず、項目が多いフォームや長文を書かせるフォームに絞ります。

dialog 要素と showModal() によるモーダル確認

モーダル方式は、入力画面の上に確認を重ねます。初期フォーカスの移動と背面の操作抑止は、自前で書かずに HTML の dialog 要素の showModal() に任せます。背景のスクロール停止は showModal() の役割に含まれないので、必要なら CSS で別に指定します。

import { useRef, type RefObject } from "react";
import { useWatch, useFormState } from "react-hook-form";

function ConfirmDialog({ dialogRef, onSubmit }: {
  dialogRef: RefObject<HTMLDialogElement | null>; onSubmit: () => void;
}) {
  const v = useWatch<FormValues>(); // 開くたびに最新の入力を表示する
  const { isSubmitting } = useFormState<FormValues>();
  return (
    <dialog
      ref={dialogRef}
      onCancel={(e) => { if (isSubmitting) e.preventDefault(); }} // 送信中は Esc で閉じない
      aria-labelledby="confirm-title"
    >
      <h2 id="confirm-title">入力内容の確認</h2>
      <dl><dt>お名前</dt><dd>{v.name}</dd>{/* 以下略 */}</dl>
      <button type="button" onClick={() => dialogRef.current?.close()} disabled={isSubmitting}>
        修正する
      </button>
      <button type="button" onClick={onSubmit} disabled={isSubmitting}>送信する</button>
    </dialog>
  );
}

// 親(FormProvider の内側に ConfirmDialog を置く)
const dialogRef = useRef<HTMLDialogElement>(null);
const goConfirm = async () => {
  if (await methods.trigger(undefined, { shouldFocus: true })) dialogRef.current?.showModal();
};
const submit = methods.handleSubmit(async (data) => {
  // 送信処理(前述)。成功したら閉じる
  dialogRef.current?.close();
});

開閉は親の useState に持たず showModal() と close() を直接呼ぶので、Esc で閉じたときに親の状態を戻す処理が要りません。確認へ進む検証を trigger() にしたのは、handleSubmit の実行中は isSubmitting が true で、開いた瞬間のボタンが無効になるためです。

この形で8回動かすと結果は毎回同じで、開くとフォーカスは「修正する」へ移り、背面の入力欄には入らず、送信中は Esc でも閉じませんでした。MDN の dialog 要素の説明も、showModal() で開くとページの残りが inert(操作不可)になり、最初のフォーカス可能な要素にフォーカスが移り、Esc キーで閉じられるとしています。

外部ストアとアンケート用ライブラリの要否

確認画面のために Zustand や Jotai を最初から入れる必要は、ほとんどのフォームでありません。

  • 単一の問い合わせフォーム → FormProvider だけで足ります。確認画面は入力画面のすぐ下にあり、Context で届く距離です。
  • カート→配送→決済のように、複数ページの入力を最後にまとめて確認する → グローバルストアが有効です。atom 単位で持つ書き方はJotaiとは?atomで組むReact状態管理の使い方とv3の変更点【3.0.1対応】で扱っています。
  • 設問が数十問あり、回答によって次の設問が変わるアンケート → 専用のフォームライブラリを検討します。SurveyJS の Form Library(npm の survey-react-ui、2026年10月28日時点で 3.2.3・MIT ライセンス)は、設問と分岐を JSON で定義して描画します。

問い合わせ1本のために外部ストアを入れるのは、採用すべきでない典型です。状態の置き場所が2つになり、送信完了時に消し忘れる場所が増えるだけです。アンケートでも1画面に収まる設問数なら、まず FormProvider と sessionStorage で始め、ページ分割や分岐が必要になった時点で見直します。回答の集計や管理画面まで含めて社内で持つ場合は、アンケート管理システム開発のように、フォームと管理側をまとめて設計する選択肢もあります。

よくある質問

Reactで確認画面は必ず別ページにすべきですか?

いいえ。問い合わせのような単一フォームなら、1コンポーネント内で step を切り替える方が、値の受け渡しと直アクセス対策が要りません。ステップごとの計測や多段の入力が必要なときだけURLを分けます。

ブラウザバックとは何ですか?なぜ入力が消えますか?

ブラウザの戻るボタンやジェスチャ、history.back() で直前の履歴へ戻る操作です。フォーム状態を持つコンポーネントがアンマウントされたり、文書が再読み込みされたりすると入力が消えます。ただし、親に状態を残す構成やbfcacheからの復元では保持される場合があります。sessionStorage からの復元と、popstate での入力・確認ステップの同期で防ぎます。

sessionStorage に個人情報を保存しても安全ですか?

タブを閉じれば消える点で localStorage より残りにくいものの、開いている間は同じオリジンの JavaScript から読めます。送信完了時に削除し、パスワードやカード番号のような値は保存対象から外します。

アンケートフォームでも同じ作り方でよいですか?

1画面に収まる設問数なら同じです。FormProvider で全設問を共有し、確認画面で getValues() を一覧表示します。設問をページに分ける場合は各ページの値を sessionStorage かストアに集め、回答による分岐が多ければ SurveyJS のような専用ライブラリを検討します。

React Hook Form を使わずに確認画面は作れますか?

作れます。親コンポーネントの useState にフォームの値を持ち、確認画面へ渡すだけです。ただし項目が増えると、検証、エラー表示、送信中の状態を自前で書くことになり、React Hook Form を使う方が短くまとまります。

関連記事

お気に入りに入れた記事の一覧

一創の公式 SNS

X では毎朝の新着記事と IT ニュースの速報を、YouTube・TikTok・Instagram・LinkedIn・Facebook では記事の解説動画を配信しています。

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.11.03 テックブログ ノジマ不正アクセスで約700万件漏えい:対象確認・窓口と乗っ取り注文を止める実装
  2. 2026.11.03 テックブログ クラブツーリズム不正アクセス:約432万件の対象確認と偽メールの見分け方・流出検知の設計
  3. 2026.10.29 テックブログ ソニー生命の情報漏洩38.5万件|ログインできない理由、再開状況と窓口の電話番号
  4. 2026.11.02 テックブログ アララの不正アクセス:メール約7,700万件の持ち出しと契約企業が行うAPIキー再設定・代替送信・連名報告
  5. 2026.11.03 テックブログ ネットマイル不正アクセスで最大549万人:対象確認と退会会員データを消す実装

RELATED POSTS 関連記事

目次