Reactで、複数ステップに分けた入力フォームを作る方法

​​React​Playwright,TypeScript,アクセシビリティ,フォーム

入力項目の多いフォームは、ステップに分けると1画面に並ぶ項目が減ります。React で作るときは、入力の値を親のコンポーネントにまとめて持ち、「次へ」をフォームの送信として扱うと、戻っても値が消えず、ブラウザの入力チェックと Enter キーもそのまま使えます。そのうえで、「戻る」ボタンには type="button" を付けます。

筆者は検証環境で「連絡先」「お届け先」「確認」の3ステップのフォームを作り、Chromium を Playwright で操作して、値の残り方やボタンの動きを確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で変換し、Chromium 153 を Playwright 1.63.0 で操作しました。ライブラリは使わず、React の useState とブラウザのフォームの機能だけで作っています。Firefox と Safari、スクリーンリーダーでの読み上げ、サーバーへの実際の送信は試していません。

3ステップのフォームで、連絡先とお届け先を入力し、戻って値が残っていることを確かめてから送信する様子
入力して「次へ」で進み、「戻る」で戻っても入力が残り、確認のあと送信するまでの動き

値は親のコンポーネントにまとめて持つ

ステップごとの部品(ContactStep など)は、表示しているあいだだけ画面にあり、ステップを切り替えると取り除かれます。React の公式ドキュメントにある state の保持とリセットの説明のとおり、React はコンポーネントを取り除くとき、その state も捨てます。

試しに、各ステップの部品が自分の useState で値を持つ書き方にして、検証環境で動かしました。お名前に「山田」と入れて「次へ」で進み、「戻る」で戻ると、お名前は空になっていました。

text
--- 各ステップが自分の state で値を持つ書き方 ---
「次へ」: 2/3 お届け先
「戻る」: 1/3 連絡先 / お名前: ""

そこで、全部のステップの値を1つの型にまとめ、ステップを切り替えても消えない親のコンポーネントに持たせます。公式ドキュメントで「state のリフトアップ」と呼ばれている形です。

ts
// types.ts
export type FormValues = {
  name: string;
  email: string;
  address: string;
  note: string;
};

export const initialValues: FormValues = { name: '', email: '', address: '', note: '' };

export const steps = ['連絡先', 'お届け先', '確認'] as const;

ステップの部品は、値と、値を書き換える関数を props で受け取るだけにします。書き換える関数には、変わった項目だけを渡します。

tsx
// Steps.tsx(連絡先のステップ)
import type { FormValues } from './types';

type StepProps = {
  values: FormValues;
  onChange: (patch: Partial<FormValues>) => void;
};

export function ContactStep({ values, onChange }: StepProps) {
  return (
    <>
      <label>
        お名前
        <input name="name" required value={values.name} onChange={(e) => onChange({ name: e.target.value })} />
      </label>
      <label>
        メール
        <input name="email" type="email" required value={values.email} onChange={(e) => onChange({ email: e.target.value })} />
      </label>
    </>
  );
}

お届け先のステップ(住所と備考)も同じ形で書き、確認のステップは values を表示するだけにしました。

「次へ」は、フォームの送信として扱う

親のコンポーネントは次のように書きました。ポイントは、<form> を1つ置き、「次へ」と最後の「送信する」を同じ type="submit" のボタンにして、onSubmit でステップを進めるところです。

tsx
// MultiStepForm.tsx
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { ContactStep, AddressStep, ConfirmStep } from './Steps';
import { initialValues, steps, type FormValues } from './types';

export function MultiStepForm({ onSend }: { onSend: (values: FormValues) => void }) {
  const [step, setStep] = useState(0);
  const [values, setValues] = useState<FormValues>(initialValues);
  const headingRef = useRef<HTMLHeadingElement>(null);
  const shouldFocus = useRef(false);

  // ステップが変わったら、そのステップの見出しにフォーカスを移す
  useEffect(() => {
    if (!shouldFocus.current) return;
    shouldFocus.current = false;
    headingRef.current?.focus();
  }, [step]);

  const update = (patch: Partial<FormValues>) => setValues((v) => ({ ...v, ...patch }));

  const goTo = (next: number) => {
    shouldFocus.current = true;
    setStep(next);
  };

  // ブラウザの入力チェック(required など)を通ったときだけ呼ばれる
  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (step < steps.length - 1) goTo(step + 1);
    else onSend(values);
  };

  const isLast = step === steps.length - 1;

  return (
    <>
      <ol className="progress">
        {steps.map((label, i) => (
          <li key={label} aria-current={i === step ? 'step' : undefined}>{label}</li>
        ))}
      </ol>
      <form onSubmit={handleSubmit}>
        <h2 ref={headingRef} tabIndex={-1}>
          {step + 1}/{steps.length} {steps[step]}
        </h2>
        {step === 0 && <ContactStep values={values} onChange={update} />}
        {step === 1 && <AddressStep values={values} onChange={update} />}
        {step === 2 && <ConfirmStep values={values} />}
        <div className="actions">
          {step > 0 && (
            <button type="button" onClick={() => goTo(step - 1)}>戻る</button>
          )}
          <button type="submit">{isLast ? '送信する' : '次へ'}</button>
        </div>
      </form>
    </>
  );
}

ブラウザは、入力欄が required や type="email" の条件を満たしていないと、フォームを送信しません。MDN の submit イベントの説明にあるとおり、このとき submit イベントは起きないので、handleSubmit も呼ばれません。画面に出ている入力欄だけがチェックされるため、ステップごとのチェックを自分で書かずに済みます。

検証環境で試したところ、空のまま「次へ」を押しても、メールに「yamada」とだけ入れて押しても、1つ目のステップから進みませんでした。メールの欄で Enter キーを押したときは、「次へ」を押したのと同じように2つ目へ進みました。

text
空のまま「次へ」: 1/3 連絡先 / お名前の validationMessage: Please fill out this field.
メールが「yamada」で「次へ」: 1/3 連絡先 / メールの typeMismatch: true
メールの欄で Enter: 2/3 お届け先 / フォーカス: h2 2/3 お届け先 / aria-current: お届け先

「戻る」で連絡先に戻っても、お名前とメールは残っていました。住所の欄で Enter を押すと確認へ進み、「送信する」で、全ステップの値が1つのオブジェクトとして onSend に渡りました。

text
住所を入れて「戻る」: 1/3 連絡先 / お名前: 山田 / メール: yamada@example.com
もう一度「次へ」: 2/3 お届け先 / 住所: 東京都
住所の欄で Enter: 3/3 確認 / お名前 山田 メール yamada@example.com 住所 東京都 備考 (なし)
送信: 送信しました:{"name":"山田","email":"yamada@example.com","address":"東京都","note":""}
注意

ブラウザの入力チェックは、入力する人の助けになるだけです。MDN の制約検証の説明にもあるとおり、チェックを通らない値が送られてくることはあるので、受け取るサーバー側でも同じ内容を検証します。

「戻る」には type="button" を付ける

<form> の中の <button> は、type を書かないと送信ボタンになります(MDN の button 要素の説明)。「戻る」に type を書き忘れると、戻った先のステップにも「戻る」があるときは、ステップを1つ戻したうえで、フォームの送信も起きます。

検証環境で、入力欄を各ステップ1つにした簡単なフォームを作り、「戻る」の type だけを書かずに試しました。このフォームでは、submit が起きた回数を画面に出しています。確認のステップで「戻る」を押すと、送信の回数が1つ増え、画面は確認のままでした。onClick で1つ戻ったあと、続けて起きた送信で、このフォームの handleSubmit が1つ進めたためです。2つ目のステップで押したときは、送信は起きませんでした。1つ目に戻ると「戻る」ボタン自体が画面から消えるためと考えられます。

text
「戻る」の type プロパティ: submit
2回「次へ」: 3/3 確認 / submit 2回
3つ目で「戻る」: 3/3 確認 / submit 3回
2つ目で「戻る」(戻ると「戻る」ボタンが消える): 1/3 連絡先 / submit 1回

上の MultiStepForm で確認の画面まで進み、「戻る」ボタンから type 属性だけを消して押したときも、画面は確認のまま動きませんでした。

もう1つ困るのが Enter キーです。HTML の仕様では、入力欄で Enter を押すと、フォームの中で最初にある送信ボタンが押されたことになります。「戻る」を「次へ」より前に置いていて type がないと、最初の送信ボタンは「戻る」です。検証環境でも、2つ目のステップの入力欄で Enter を押すと、1つ目のステップに戻りました。

text
2つ目の入力欄で Enter: 1/3 連絡先 / submit 1回

type="button" を付けた「戻る」は送信ボタンではないので、Enter で押されるのは「次へ」だけになります。前の節の結果のとおり、住所の欄で Enter を押すと確認へ進みました。

ステップが変わったら、見出しにフォーカスを移す

ステップを切り替えると、Enter を押した入力欄のように、それまでフォーカスがあった要素が画面から取り除かれることがあります。MultiStepForm では、ステップを変えたあと、そのステップの見出しへフォーカスを移しています。キーボードで操作する人が、新しいステップの始まりを見失わないようにするためです。見出しは本来フォーカスを受けない要素なので、tabIndex={-1} を付けます。MDN の tabindex の説明のとおり、-1 にすると Tab キーでは止まらず、スクリプトからはフォーカスできます。

最初に画面を開いたときはフォーカスを動かさないよう、goTo で shouldFocus を立てたときだけ、エフェクトの中で focus() を呼んでいます。上の検証結果の「メールの欄で Enter」の行のとおり、2つ目へ進んだあとのフォーカスは見出し(h2)にありました。

ステップの一覧では、いまのステップの項目にだけ aria-current="step" を付けました。MDN の aria-current の説明では、複数ステップの手続きで、見た目で区別しているいまのステップを支援技術に伝える値として step が挙げられています。検証環境では、進むたびに aria-current の付いた項目が「連絡先」から「お届け先」に移りました。

​Posted by 86