SCALE — Build Lab
開発パターン · REACT PATTERN

マルチステップフォーム

CATEGORY開発パターン TYPEReact Pattern EFFORT120〜360分 DIFFICULTY
PRIMARY CODE
tsx
import { useState, ReactNode } from 'react';

type Step<T> = {
  title: string;
  validate?: (data: T) => string | null;
  render: (data: T, set: (patch: Partial<T>) => void) => ReactNode;
};

export function MultiStepForm<T extends Record<string, any>>({
  initial, steps, onSubmit,
}: {
  initial: T;
  steps: Step<T>[];
  onSubmit: (data: T) => Promise<void>;
}) {
  const [data, setData] = useState<T>(initial);
  const [step, setStep] = useState(0);
  const [error, setError] = useState<string | null>(null);
  const set = (patch: Partial<T>) => setData((d) => ({ ...d, ...patch }));

  const current = steps[step];
  const progress = ((step + 1) / steps.length) * 100;

  const next = async () => {
    const err = current.validate?.(data) ?? null;
    if (err) { setError(err); return; }
    setError(null);
    if (step === steps.length - 1) {
      await onSubmit(data);
    } else {
      setStep(step + 1);
    }
  };

  return (
    <div className="max-w-xl mx-auto">
      <div className="h-1 bg-zinc-800 rounded-full mb-2">
        <div className="h-full bg-indigo-500 rounded-full transition-all" style={{ width: `${progress}%` }} />
      </div>
      <p className="text-xs text-zinc-400 mb-4">Step {step + 1} / {steps.length}: {current.title}</p>
      {current.render(data, set)}
      {error && <p className="text-rose-400 text-sm mt-2">{error}</p>}
      <div className="flex justify-between mt-6">
        <button disabled={step === 0} onClick={() => setStep(step - 1)} className="px-4 py-2 rounded-lg bg-zinc-800 disabled:opacity-50">戻る</button>
        <button onClick={next} className="px-4 py-2 rounded-lg bg-indigo-500 text-white">
          {step === steps.length - 1 ? '送信' : '次へ'}
        </button>
      </div>
    </div>
  );
}
前提条件
Tailwind CSS v4TypeScript 5
USE CASES
  • オンボーディング
  • 応募フォーム
  • 商談入力

マルチステップフォーム

:LiTarget: 用途

複数ステップに分けた長いフォームを管理するパターン。

:LiSparkle: 特徴

  • ステップ管理
  • バリデーション
  • プログレスバー
  • 前後移動

:LiCode: コード(コピペ用)

import { useState, ReactNode } from 'react';

type Step<T> = {
  title: string;
  validate?: (data: T) => string | null;
  render: (data: T, set: (patch: Partial<T>) => void) => ReactNode;
};

export function MultiStepForm<T extends Record<string, any>>({
  initial, steps, onSubmit,
}: {
  initial: T;
  steps: Step<T>[];
  onSubmit: (data: T) => Promise<void>;
}) {
  const [data, setData] = useState<T>(initial);
  const [step, setStep] = useState(0);
  const [error, setError] = useState<string | null>(null);
  const set = (patch: Partial<T>) => setData((d) => ({ ...d, ...patch }));

  const current = steps[step];
  const progress = ((step + 1) / steps.length) * 100;

  const next = async () => {
    const err = current.validate?.(data) ?? null;
    if (err) { setError(err); return; }
    setError(null);
    if (step === steps.length - 1) {
      await onSubmit(data);
    } else {
      setStep(step + 1);
    }
  };

  return (
    <div className="max-w-xl mx-auto">
      <div className="h-1 bg-zinc-800 rounded-full mb-2">
        <div className="h-full bg-indigo-500 rounded-full transition-all" style={{ width: `${progress}%` }} />
      </div>
      <p className="text-xs text-zinc-400 mb-4">Step {step + 1} / {steps.length}: {current.title}</p>
      {current.render(data, set)}
      {error && <p className="text-rose-400 text-sm mt-2">{error}</p>}
      <div className="flex justify-between mt-6">
        <button disabled={step === 0} onClick={() => setStep(step - 1)} className="px-4 py-2 rounded-lg bg-zinc-800 disabled:opacity-50">戻る</button>
        <button onClick={next} className="px-4 py-2 rounded-lg bg-indigo-500 text-white">
          {step === steps.length - 1 ? '送信' : '次へ'}
        </button>
      </div>
    </div>
  );
}

:LiHandPointer: 使い方

対象プロジェクトに該当ファイルをコピーして、props を流し込むだけ。

:LiAlertCircle: 注意事項

  • 依存パッケージを忘れず追加