React

Reactで確認画面付きフォームを作る方法(入力→確認→送信とデータ保持)

問い合わせやアンケートのフォームで「入力 → 確認 → 送信」の3ステップを組むとき、Reactでつまずくのは画面遷移そのものではなく入力値の受け渡しと保持です。確認画面で値が表示されない、戻ると入力が消える、リロードで初期化される、ブラウザバックで白紙に戻る——これらはフォーム離脱の直接の原因になります。この記事では、React Hook Form と FormProvider を使った確認画面付きフォームの実装を軸に、リロード・直アクセス・ブラウザバックで入力値を失わない対策までを実コードで解説します。

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

  • 状態は1箇所に集約する。入力画面と確認画面で同じフォーム状態を共有できれば、確認画面は「その状態を読み取り専用で描画するだけ」になる。React Hook Form の FormProvideruseFormContext がこの共有を担う。
  • 画面はルーティングで分けず、1コンポーネント内のステップ切り替えが扱いやすい。URLを分けると値の受け渡しと直アクセス対策が増える。まずは step 状態でinput/confirmを切り替える構成が最小コスト。
  • 確認画面での「戻る」は状態を壊さない。状態を保持したまま表示だけ戻せば、入力値はそのまま残る。
  • リロード・直リンク・ブラウザバックは別問題。これらはReactの状態を消すため、sessionStorage への一時保存+復元、または入力画面へのリダイレクトで守る。
  • 個人情報を localStorage に永続保存しない。確認画面の一時保持は sessionStorage(タブを閉じれば消える)が既定。

確認画面付きフォームの基本構成(入力→確認→完了)

確認画面付きフォームの設計判断は、最初に「画面をどう分けるか」に集約されます。選択肢は2つです。

構成 入力値の受け渡し 直アクセス対策 向くケース
1コンポーネントでステップ切替 不要(同じstateを共有) ほぼ不要 問い合わせ・単一フォーム
ルーティングで画面分割 必要(Context/Store/state経由) 必須(直リンク・リロード) 多段ウィザード・URL共有が要る

結論から言えば、単一フォームならルーティングで分けない方が破綻しにくいです。URLを /confirm に分けた瞬間、その画面へ直接アクセスされたときや確認画面でリロードされたときに入力値が空になり、それを埋める分岐が必要になります。まずは1つのコンポーネントの中で入力ステップと確認ステップを状態で切り替える構成を基本形にします。

import { useForm, FormProvider } from "react-hook-form";
import { useState } from "react";

type FormValues = { name: string; email: string; message: string };

export function ContactForm() {
  const methods = useForm<FormValues>({ mode: "onBlur" });
  const [step, setStep] = useState<"input" | "confirm">("input");

  const goConfirm = methods.handleSubmit(() => setStep("confirm"));
  const submit = methods.handleSubmit((data) => {
    // ここでAPI送信し、完了画面へ
  });

  return (
    <FormProvider {...methods}>
      {step === "input"
        ? <InputStep onNext={goConfirm} />
        : <ConfirmStep onBack={() => setStep("input")} onSubmit={submit} />}
    </FormProvider>
  );
}

handleSubmit を「次へ」に噛ませているのがポイントです。確認画面へ進む前にバリデーションを1回通すことで、不正な値のまま確認画面に到達するのを防げます。完了画面は送信成功後に step をもう1段増やす("done")か、送信完了ページへ遷移させます。

React Hook Form + FormProvider で入力値を確認画面へ渡す

確認画面に値を「渡す」実装で最も手間がかかるのが props のバケツリレーです。React Hook Form の FormProvider は React の Context API 上に構築されており、配下のどのコンポーネントからでも useFormContext でフォーム状態にアクセスできます。確認画面コンポーネントは props を一切受け取らずに入力値を読み出せます。

FormProvider と useFormContext でフォーム状態を共有する

上の ContactForm<FormProvider {...methods}> がフォーム状態を配布し、子は次のように受け取ります。

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

function InputStep({ onNext }: { onNext: () => void }) {
  const { register } = useFormContext<FormValues>();
  return (
    <div>
      <input {...register("name", { required: "お名前は必須です" })} />
      <input {...register("email", { required: true })} />
      <textarea {...register("message")} />
      <button type="button" onClick={onNext}>確認画面へ</button>
    </div>
  );
}

確認画面で入力値を読み取り専用表示し「戻って修正」する

確認画面は同じ状態を getValues で読み、表示するだけです。編集はさせず、「入力へ戻る」で step を戻せば、状態は保持されたまま入力画面に戻れます——ここでReactの状態を捨てないことが「戻ると消える」を防ぐ核心です。

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

function ConfirmStep({ onBack, onSubmit }: {
  onBack: () => void; onSubmit: () => void;
}) {
  const { getValues } = useFormContext<FormValues>();
  const v = getValues();
  return (
    <div>
      <dl>
        <dt>お名前</dt><dd>{v.name}</dd>
        <dt>メール</dt><dd>{v.email}</dd>
        <dt>お問い合わせ</dt><dd>{v.message}</dd>
      </dl>
      <button type="button" onClick={onBack}>入力へ戻る</button>
      <button type="button" onClick={onSubmit}>送信する</button>
    </div>
  );
}

入力値のうち一部だけを表示したいなら getValues("name") のようにフィールド指定でも取れます。確認画面はレンダー時点の値でよいため、再レンダーを購読する watch ではなく getValues で十分です。

バリデーションのタイミングを mode で設計する

useForm({ mode }) は、どのタイミングでバリデーションを走らせるかを決めます。確認画面付きフォームでは、確認へ進む前に handleSubmit が最終チェックをするため、道中の体験を mode で調整します。

mode 検証タイミング 使いどころ
onSubmit(既定) 送信時のみ 短いフォーム
onBlur フォーカスを外した時 入力ごとに指摘しつつ煩わしくしない
onChange 入力のたび 即時フィードバックが要るUI(再レンダー増)

なお FormProvider 配下で errorsisDirty を購読するときは、useFormContext().formState を分割代入するより useFormState() を使う方が再レンダーの範囲を絞れます。フォームの基礎的な使い方やバリデーション(Zod連携など)はReact Hook Formとは?使い方・バリデーション・v7の書き方を実例で解説で詳しく扱っています。

リロード・直アクセス・ブラウザバックで入力値を失わない対策

ここまでの構成なら「確認画面で戻る」では値は消えません。消えるのはReactの状態そのものが破棄される操作——ページのリロード、確認画面URLへの直アクセス、ブラウザバックでの離脱——です。対策を分けて実装します。

sessionStorage で入力値を一時保存・復元する

入力のたびに sessionStorage へ書き、初回マウントで復元します。sessionStorage はタブを閉じると消え、リロードでは残るため、確認画面フローの一時保持に適します。watch の購読で値の変化を拾い、reset で復元します。

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

const KEY = "contact-form";

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

  // 復元(初回のみ)
  useEffect(() => {
    const saved = sessionStorage.getItem(KEY);
    if (!saved) return;
    try {
      reset(JSON.parse(saved));
    } catch {
      sessionStorage.removeItem(KEY); // 壊れた値は握りつぶす
    }
  }, [reset]);

  // 保存(入力のたび)
  useEffect(() => {
    const sub = watch((values) => {
      sessionStorage.setItem(KEY, JSON.stringify(values));
    });
    return () => sub.unsubscribe();
  }, [watch]);
}

このカスタムフックは FormProvider の内側で呼び出します(useFormContext が Context を必要とするため)。React Router でページを分ける構成でも考え方は同じで、キーを画面間で共有すれば /confirm 側の初回マウントで sessionStorage から reset して復元できます(「react router session storage」で探されるのはこのパターンです)。項目が多く打鍵ごとの保存が重いフォームでは setItem をdebounceで間引きます。送信が完了したら sessionStorage.removeItem(KEY) で必ず消します。

リロード・直リンク時は入力画面へリダイレクトする

確認画面を独立URLにしている場合、直リンクやリロードで状態が空のまま到達することがあります。復元データが無ければ入力画面へ戻す番人を置きます。なおこの番人はルーティングで確認画面を独立URLにした構成向けです。前述の1コンポーネント内で step を切り替える構成なら、リロードで step が初期値 "input" に戻るため番人は不要です。

useEffect(() => {
  if (step === "confirm" && !sessionStorage.getItem(KEY)) {
    setStep("input"); // ルーティング構成なら navigate("/input")
  }
}, [step]);

popstate と History API でブラウザバックを検知する

ブラウザの戻るボタンを「入力→確認の1ステップ戻し」として扱いたいときは、確認画面に入る際に履歴を1つ積み、popstate で検知して step を戻します。これで戻るボタンでもサイトから離脱せず、入力画面へ戻せます。

useEffect(() => {
  // 確認画面に入るとき履歴を積む
  window.history.pushState({ step: "confirm" }, "");
  const onPop = () => setStep("input");
  window.addEventListener("popstate", onPop);
  return () => window.removeEventListener("popstate", onPop);
}, []);

このeffectは確認画面コンポーネント(ConfirmStep)側に置き、確認画面のマウント時に履歴を積みます。ContactForm 直下に置くと入力ステップの表示時に積んでしまうので注意します。DOM参照や値の保持で useRef を使い分ける場面はReactのuseRefの使い方|useStateとの違い・DOM参照・値の保持を実例で解説にまとめています。useStateuseEffect 自体の基礎はReactフックの基本と導入を参照してください。

sessionStorage と localStorage の使い分け

保存先 タブを閉じる リロード 別タブと共有 確認画面での既定
sessionStorage 消える 残る 共有しない 推奨(一時保持)
localStorage 残る 残る 共有する 非推奨(永続・個人情報リスク)

「下書きを次回も引き継ぐ」明確な要件がない限り、確認画面の一時保持に localStorage を使うべきではありません。氏名・メール・問い合わせ内容といった個人情報がブラウザに残り続け、共有端末では他者に見える事故につながります。

離脱防止と確認ダイアログ(フォーム離脱改善)

入力途中でタブを閉じる・別URLへ移動する操作に対しては、ブラウザ標準の離脱確認を出せます。未保存(isDirty)のときだけ発火させるのがコツです。

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

function useLeaveGuard() {
  const { isDirty } = useFormState();
  useEffect(() => {
    const handler = (e: BeforeUnloadEvent) => {
      if (!isDirty) return;
      e.preventDefault();       // 現行ブラウザはこれで確認ダイアログが出る
      e.returnValue = "";       // 一部の旧ブラウザ向け
    };
    window.addEventListener("beforeunload", handler);
    return () => window.removeEventListener("beforeunload", handler);
  }, [isDirty]);
}

このフックも FormProvider の内側で使います(useFormState が Context を参照するため)。beforeunload のダイアログ文言はブラウザ側で固定され、任意テキストは表示できません。アプリ内の画面遷移(React Router 内の遷移)を止めたい場合は beforeunload では捕捉できないため、ルーターのナビゲーションブロック機能(useBlocker 等)で別途ガードします。離脱防止は「入れれば良い」ものではなく、確実に送信まで進めたい長いフォームに絞って使うのが無難です。短い問い合わせフォームで毎回ダイアログを出すと、かえって離脱を招きます。

状態管理ライブラリと確認画面の要否判断

確認画面のために Recoil や Jotai、Zustand、location-state といった状態管理ライブラリを最初から入れる必要は、ほとんどのフォームでありません。ここは立場を明確にします。

  • 単一の確認画面付きフォーム → React Hook Form の FormProvider だけで足りる。外部ストアは不要。
  • 複数ページ・複数フォームで入力値を横断利用する(例:カート→配送→決済、長いアンケートの分割送信)→ Zustand などのグローバルストアが有効。フォーム状態を画面をまたいで一元管理できる。
  • ブラウザ履歴に状態を同期したい(戻る/進むで各ステップの状態を復元)→ location-state のように履歴と状態を結びつけるライブラリが選択肢。ただし学習コストと引き換えなので、sessionStoragepopstate で足りるなら導入しない。

採用すべきでない場面:問い合わせ1本のために Recoil/Jotai を導入するのは過剰です。グローバル状態は「複数の遠いコンポーネントが同じ値を必要とする」ときに初めて元が取れます。確認画面は入力画面の直下にあり、FormProvider の Context で届く距離なので、外部ストアの導入は複雑さだけが増えます。アンケートのように項目が多いフォームでも、1画面に収まるなら判断は同じで、まず FormProvidersessionStorage から始めて、ページ分割が必要になった時点でストアを検討します。

よくある質問

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

いいえ。単一フォームなら1コンポーネント内で step を切り替える方が、値の受け渡しと直アクセス対策の手間が減ります。URL共有や多段ウィザードが要件のときだけルーティング分割を選びます。

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

項目数が多くても1画面に収まるなら同じです。FormProvider で全項目を共有し、確認画面で getValues を一覧表示します。設問をページ分割する場合は、各ページの値を sessionStorage かグローバルストアに集約してから確認画面でまとめて表示します。

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

タブを閉じれば消える点で localStorage よりは安全ですが、開いている間は同一オリジンのJavaScriptから読めます。送信完了時に removeItem で必ず消し、機微な情報は保持対象から外すか暗号化を検討します。

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

ブラウザの「戻る」操作(戻るボタン・ジェスチャ・history.back())で直前の履歴に戻ることです。SPAでコンポーネントがアンマウントされたり別ページへ戻ると、Reactの状態は破棄されるため入力が消えます。sessionStorage 復元や popstate 検知で対処します。

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

作れます。useState でフォーム値を親に持ち、確認画面へ渡すだけです。ただし項目が増えるとバリデーションと再レンダー最適化を自前で書くことになり、React Hook Form を使う方が短く安全にまとまります。

関連記事

資料請求

RELATED POSTS 関連記事