T-CREATOR

Zustandでフォームの状態を管理する:React Hook Formとの併用パターン

Zustandでフォームの状態を管理する:React Hook Formとの併用パターン

フロントエンド開発において、フォームの状態管理は避けて通れない重要な課題です。特に複数のフォームが連携するアプリケーションや、段階的な入力プロセスを持つシステムでは、単純なローカル状態管理では限界があります。

そこで注目されているのが、Zustand と React Hook Form を組み合わせたアプローチです。この組み合わせにより、フォームの複雑な状態管理を効率的に行いながら、パフォーマンスも最適化できるのです。

本記事では、実際の開発現場で活用できる具体的な実装パターンをご紹介します。

背景

フロントエンドにおけるフォーム状態管理の課題

現代の Web アプリケーションでは、フォームの要件がますます複雑化しています。

単一のフォームであっても、リアルタイムバリデーション、条件付きフィールド表示、途中保存機能など、多くの機能が求められます。さらに、複数のフォームが連携するケースでは、データの整合性を保ちながら状態を管理する必要があります。

従来の React のuseStateや Context API だけでは、以下のような課題に直面することが多いでしょう。

#課題影響
1状態の分散化データの一貫性が保てない
2再レンダリングの頻発パフォーマンスの劣化
3複雑な状態更新ロジックメンテナンス性の低下

React Hook Form の特徴と Zustand との相性

React Hook Form は、フォームの状態管理に特化したライブラリです。

最小限の再レンダリングでフォームを管理し、優れたパフォーマンスを実現します。一方で、React Hook Form は単一フォーム内での状態管理に特化しており、複数フォーム間でのデータ共有には別のアプローチが必要です。

ここで Zustand が威力を発揮します。Zustand の軽量で直感的な API は、React Hook Form との連携において以下のメリットをもたらします。

typescript// Zustandストアの例
import { create } from 'zustand';

interface FormStore {
  // フォーム間で共有するデータ
  sharedData: {
    userId: string;
    sessionId: string;
  };
  // フォームの状態管理
  updateSharedData: (
    data: Partial<FormStore['sharedData']>
  ) => void;
}

const useFormStore = create<FormStore>((set) => ({
  sharedData: {
    userId: '',
    sessionId: '',
  },
  updateSharedData: (data) =>
    set((state) => ({
      sharedData: { ...state.sharedData, ...data },
    })),
}));

この組み合わせにより、フォーム固有の状態は React Hook Form で管理し、アプリケーション全体で共有すべき状態は Zustand で管理するという、責任の分離が実現できます。

課題

複数フォーム間でのデータ共有の困難さ

実際の開発現場では、複数のフォームが連携するケースが頻繁に発生します。

例えば、EC サイトでの購入プロセスでは、商品選択フォーム、配送先入力フォーム、決済情報入力フォームが順次表示され、それぞれのデータが後続のフォームに影響を与えます。

従来の手法では、親コンポーネントで props を通じてデータを受け渡すか、Context API を使用することが一般的でした。しかし、これらの手法には以下の問題があります。

typescript// 問題のあるパターン:props drilling
const CheckoutProcess = () => {
  const [step1Data, setStep1Data] = useState({});
  const [step2Data, setStep2Data] = useState({});
  const [step3Data, setStep3Data] = useState({});

  return (
    <>
      <Step1Form onSubmit={setStep1Data} />
      <Step2Form
        step1Data={step1Data}
        onSubmit={setStep2Data}
      />
      <Step3Form
        step1Data={step1Data}
        step2Data={step2Data}
        onSubmit={setStep3Data}
      />
    </>
  );
};

このアプローチでは、フォームが増えるたびに props の管理が複雑になり、コンポーネントの再利用性も損なわれてしまいます。

フォーム状態の永続化とライフサイクル管理

ユーザー体験を向上させるためには、フォームの途中保存機能や、ページリロード後の状態復元機能が重要です。

しかし、React Hook Form の状態は基本的にコンポーネントのライフサイクルに依存するため、コンポーネントがアンマウントされると状態が失われてしまいます。

また、ブラウザの戻るボタンやページリフレッシュに対応するためには、適切な永続化戦略が必要になります。

バリデーション結果の全体的な管理

複数のフォームが連携する場合、個々のフォームのバリデーション結果を統合して、全体の進行可否を判断する必要があります。

例えば、ユーザー登録プロセスにおいて、基本情報入力、プロフィール設定、利用規約同意の 3 つのステップがある場合、すべてのステップが完了するまで最終的な登録ボタンを無効化する必要があります。

React Hook Form だけでは、このような横断的なバリデーション状態の管理は困難です。

解決策

Zustand ストアでのフォームメタ情報管理

Zustand を活用することで、フォームのメタ情報を効率的に管理できます。

フォームの進行状況、バリデーション状態、エラー情報などを一元管理することで、アプリケーション全体でのフォーム状態の把握が容易になります。

typescriptimport { create } from 'zustand';
import { persist } from 'zustand/middleware';

// フォームの状態を表すインターフェース
interface FormStep {
  id: string;
  isCompleted: boolean;
  isValid: boolean;
  errors: string[];
  data: Record<string, any>;
}

interface FormFlowStore {
  // 現在のステップ
  currentStep: number;
  // 各ステップの状態
  steps: FormStep[];
  // 全体の進行状況
  overallProgress: number;

  // アクション
  updateStepData: (
    stepId: string,
    data: Record<string, any>
  ) => void;
  setStepValid: (stepId: string, isValid: boolean) => void;
  setStepCompleted: (
    stepId: string,
    isCompleted: boolean
  ) => void;
  nextStep: () => void;
  previousStep: () => void;
  resetForm: () => void;
}

const useFormFlowStore = create<FormFlowStore>()(
  persist(
    (set, get) => ({
      currentStep: 0,
      steps: [
        {
          id: 'basic-info',
          isCompleted: false,
          isValid: false,
          errors: [],
          data: {},
        },
        {
          id: 'profile',
          isCompleted: false,
          isValid: false,
          errors: [],
          data: {},
        },
        {
          id: 'preferences',
          isCompleted: false,
          isValid: false,
          errors: [],
          data: {},
        },
      ],
      overallProgress: 0,

      updateStepData: (stepId, data) =>
        set((state) => ({
          steps: state.steps.map((step) =>
            step.id === stepId
              ? { ...step, data: { ...step.data, ...data } }
              : step
          ),
        })),

      setStepValid: (stepId, isValid) =>
        set((state) => {
          const updatedSteps = state.steps.map((step) =>
            step.id === stepId ? { ...step, isValid } : step
          );
          const completedSteps = updatedSteps.filter(
            (step) => step.isCompleted
          ).length;
          const overallProgress =
            (completedSteps / updatedSteps.length) * 100;

          return {
            steps: updatedSteps,
            overallProgress,
          };
        }),

      setStepCompleted: (stepId, isCompleted) =>
        set((state) => ({
          steps: state.steps.map((step) =>
            step.id === stepId
              ? { ...step, isCompleted }
              : step
          ),
        })),

      nextStep: () =>
        set((state) => ({
          currentStep: Math.min(
            state.currentStep + 1,
            state.steps.length - 1
          ),
        })),

      previousStep: () =>
        set((state) => ({
          currentStep: Math.max(state.currentStep - 1, 0),
        })),

      resetForm: () =>
        set({
          currentStep: 0,
          steps: [
            {
              id: 'basic-info',
              isCompleted: false,
              isValid: false,
              errors: [],
              data: {},
            },
            {
              id: 'profile',
              isCompleted: false,
              isValid: false,
              errors: [],
              data: {},
            },
            {
              id: 'preferences',
              isCompleted: false,
              isValid: false,
              errors: [],
              data: {},
            },
          ],
          overallProgress: 0,
        }),
    }),
    {
      name: 'form-flow-storage',
    }
  )
);

このストア設計により、フォームの状態が永続化され、ページリロード後も状態が復元されます。また、persistミドルウェアを使用することで、ローカルストレージへの自動保存も実現できます。

React Hook Form との連携パターン

React Hook Form と Zustand を効果的に連携させるためには、適切な責任分離が重要です。

以下のパターンでは、フォーム固有の状態管理は React Hook Form に任せ、アプリケーション全体で必要な情報のみを Zustand で管理します。

typescriptimport { useForm } from 'react-hook-form';
import { useFormFlowStore } from './store';

interface BasicInfoForm {
  firstName: string;
  lastName: string;
  email: string;
  phone: string;
}

const BasicInfoStep = () => {
  const {
    updateStepData,
    setStepValid,
    setStepCompleted,
    steps,
  } = useFormFlowStore();

  const currentStepData =
    steps.find((step) => step.id === 'basic-info')?.data ||
    {};

  const {
    register,
    handleSubmit,
    watch,
    formState: { errors, isValid },
  } = useForm<BasicInfoForm>({
    defaultValues: currentStepData,
    mode: 'onChange',
  });

  // フォームの値を監視してZustandストアを更新
  const watchedValues = watch();

  React.useEffect(() => {
    updateStepData('basic-info', watchedValues);
    setStepValid('basic-info', isValid);
  }, [
    watchedValues,
    isValid,
    updateStepData,
    setStepValid,
  ]);

  const onSubmit = (data: BasicInfoForm) => {
    updateStepData('basic-info', data);
    setStepCompleted('basic-info', true);
    // 次のステップへ進む処理
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor='firstName'>名前</label>
        <input
          {...register('firstName', {
            required: '名前は必須です',
            minLength: {
              value: 2,
              message: '2文字以上で入力してください',
            },
          })}
        />
        {errors.firstName && (
          <span className='error'>
            {errors.firstName.message}
          </span>
        )}
      </div>

      <div>
        <label htmlFor='lastName'></label>
        <input
          {...register('lastName', {
            required: '姓は必須です',
            minLength: {
              value: 2,
              message: '2文字以上で入力してください',
            },
          })}
        />
        {errors.lastName && (
          <span className='error'>
            {errors.lastName.message}
          </span>
        )}
      </div>

      <div>
        <label htmlFor='email'>メールアドレス</label>
        <input
          type='email'
          {...register('email', {
            required: 'メールアドレスは必須です',
            pattern: {
              value:
                /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
              message:
                '有効なメールアドレスを入力してください',
            },
          })}
        />
        {errors.email && (
          <span className='error'>
            {errors.email.message}
          </span>
        )}
      </div>

      <button type='submit' disabled={!isValid}>
        次へ進む
      </button>
    </form>
  );
};

このパターンでは、React Hook Form がフォームの詳細な状態管理を担当し、Zustand は必要な情報のみを保持します。watchを使用してフォームの変更をリアルタイムで監視し、Zustand ストアを更新することで、両者の同期を保っています。

カスタムフックによる統合アプローチ

React Hook Form と Zustand の連携をより簡潔に行うために、カスタムフックを作成することをお勧めします。

typescriptimport {
  useForm,
  UseFormProps,
  FieldValues,
} from 'react-hook-form';
import { useFormFlowStore } from './store';
import { useEffect } from 'react';

interface UseFormWithStoreOptions<T extends FieldValues>
  extends UseFormProps<T> {
  stepId: string;
  onStepComplete?: (data: T) => void;
}

export const useFormWithStore = <T extends FieldValues>({
  stepId,
  onStepComplete,
  ...formOptions
}: UseFormWithStoreOptions<T>) => {
  const {
    updateStepData,
    setStepValid,
    setStepCompleted,
    steps,
  } = useFormFlowStore();

  // 現在のステップのデータを取得
  const currentStepData =
    steps.find((step) => step.id === stepId)?.data || {};

  // React Hook Formを初期化
  const form = useForm<T>({
    ...formOptions,
    defaultValues: {
      ...formOptions.defaultValues,
      ...currentStepData,
    },
    mode: 'onChange',
  });

  const {
    watch,
    formState: { isValid },
  } = form;

  // フォームの値を監視してストアを更新
  const watchedValues = watch();

  useEffect(() => {
    updateStepData(stepId, watchedValues);
    setStepValid(stepId, isValid);
  }, [
    watchedValues,
    isValid,
    stepId,
    updateStepData,
    setStepValid,
  ]);

  // カスタムsubmitハンドラー
  const handleSubmitWithStore = (
    onSubmit: (data: T) => void
  ) => {
    return form.handleSubmit((data) => {
      updateStepData(stepId, data);
      setStepCompleted(stepId, true);
      onSubmit(data);
      onStepComplete?.(data);
    });
  };

  return {
    ...form,
    handleSubmitWithStore,
  };
};

このカスタムフックを使用することで、各フォームコンポーネントの実装がより簡潔になります。

typescriptconst BasicInfoStep = () => {
  const {
    register,
    handleSubmitWithStore,
    formState: { errors },
  } = useFormWithStore<BasicInfoForm>({
    stepId: 'basic-info',
    onStepComplete: (data) => {
      console.log('基本情報の入力が完了しました:', data);
    },
  });

  const onSubmit = (data: BasicInfoForm) => {
    // 次のステップへの遷移処理
    console.log('送信データ:', data);
  };

  return (
    <form onSubmit={handleSubmitWithStore(onSubmit)}>
      {/* フォームフィールド */}
    </form>
  );
};

具体例

ユーザー登録フォームでの実装例

実際のユーザー登録プロセスを例に、Zustand と React Hook Form の連携パターンを詳しく見てみましょう。

まず、ユーザー登録に必要な情報を段階的に収集するフローを設計します。

typescript// types/user.ts
export interface UserRegistration {
  // 基本情報
  basicInfo: {
    firstName: string;
    lastName: string;
    email: string;
    password: string;
    confirmPassword: string;
  };
  // プロフィール情報
  profile: {
    birthDate: string;
    gender: 'male' | 'female' | 'other';
    occupation: string;
    bio: string;
  };
  // 設定情報
  preferences: {
    newsletter: boolean;
    notifications: boolean;
    privacy: 'public' | 'private';
    language: string;
  };
}

次に、登録プロセス全体を管理する Zustand ストアを作成します。

typescript// store/registrationStore.ts
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
import { UserRegistration } from '../types/user';

interface RegistrationStore {
  // 登録データ
  registrationData: Partial<UserRegistration>;
  // 現在のステップ
  currentStep: number;
  // 各ステップの完了状態
  completedSteps: Set<number>;
  // 登録処理の状態
  isSubmitting: boolean;
  submitError: string | null;

  // アクション
  updateBasicInfo: (
    data: Partial<UserRegistration['basicInfo']>
  ) => void;
  updateProfile: (
    data: Partial<UserRegistration['profile']>
  ) => void;
  updatePreferences: (
    data: Partial<UserRegistration['preferences']>
  ) => void;
  setCurrentStep: (step: number) => void;
  markStepCompleted: (step: number) => void;
  submitRegistration: () => Promise<void>;
  resetRegistration: () => void;
}

export const useRegistrationStore =
  create<RegistrationStore>()(
    devtools(
      persist(
        (set, get) => ({
          registrationData: {},
          currentStep: 0,
          completedSteps: new Set(),
          isSubmitting: false,
          submitError: null,

          updateBasicInfo: (data) =>
            set((state) => ({
              registrationData: {
                ...state.registrationData,
                basicInfo: {
                  ...state.registrationData.basicInfo,
                  ...data,
                },
              },
            })),

          updateProfile: (data) =>
            set((state) => ({
              registrationData: {
                ...state.registrationData,
                profile: {
                  ...state.registrationData.profile,
                  ...data,
                },
              },
            })),

          updatePreferences: (data) =>
            set((state) => ({
              registrationData: {
                ...state.registrationData,
                preferences: {
                  ...state.registrationData.preferences,
                  ...data,
                },
              },
            })),

          setCurrentStep: (step) =>
            set({ currentStep: step }),

          markStepCompleted: (step) =>
            set((state) => ({
              completedSteps: new Set([
                ...state.completedSteps,
                step,
              ]),
            })),

          submitRegistration: async () => {
            set({ isSubmitting: true, submitError: null });
            try {
              const { registrationData } = get();
              // API呼び出し
              const response = await fetch(
                '/api/register',
                {
                  method: 'POST',
                  headers: {
                    'Content-Type': 'application/json',
                  },
                  body: JSON.stringify(registrationData),
                }
              );

              if (!response.ok) {
                throw new Error('登録に失敗しました');
              }

              // 成功時の処理
              set({ isSubmitting: false });
            } catch (error) {
              set({
                isSubmitting: false,
                submitError:
                  error instanceof Error
                    ? error.message
                    : '不明なエラー',
              });
            }
          },

          resetRegistration: () =>
            set({
              registrationData: {},
              currentStep: 0,
              completedSteps: new Set(),
              isSubmitting: false,
              submitError: null,
            }),
        }),
        {
          name: 'user-registration',
          // パスワードなどの機密情報は永続化しない
          partialize: (state) => ({
            registrationData: {
              ...state.registrationData,
              basicInfo: state.registrationData.basicInfo
                ? {
                    ...state.registrationData.basicInfo,
                    password: undefined,
                    confirmPassword: undefined,
                  }
                : undefined,
            },
            currentStep: state.currentStep,
            completedSteps: state.completedSteps,
          }),
        }
      ),
      { name: 'registration-store' }
    )
  );

基本情報入力フォームの実装例です。

typescript// components/BasicInfoForm.tsx
import { useForm } from 'react-hook-form';
import { useRegistrationStore } from '../store/registrationStore';
import { UserRegistration } from '../types/user';

type BasicInfoFormData = UserRegistration['basicInfo'];

export const BasicInfoForm = () => {
  const {
    registrationData,
    updateBasicInfo,
    setCurrentStep,
    markStepCompleted,
  } = useRegistrationStore();

  const {
    register,
    handleSubmit,
    watch,
    formState: { errors, isValid },
  } = useForm<BasicInfoFormData>({
    defaultValues: registrationData.basicInfo || {},
    mode: 'onChange',
  });

  // パスワード確認のためのwatch
  const password = watch('password');

  const onSubmit = (data: BasicInfoFormData) => {
    updateBasicInfo(data);
    markStepCompleted(0);
    setCurrentStep(1); // 次のステップへ
  };

  return (
    <div className='form-container'>
      <h2>基本情報の入力</h2>
      <form
        onSubmit={handleSubmit(onSubmit)}
        className='registration-form'
      >
        <div className='form-group'>
          <label htmlFor='firstName'>名前 *</label>
          <input
            {...register('firstName', {
              required: '名前は必須です',
              minLength: {
                value: 2,
                message: '2文字以上で入力してください',
              },
            })}
            className={errors.firstName ? 'error' : ''}
          />
          {errors.firstName && (
            <span className='error-message'>
              {errors.firstName.message}
            </span>
          )}
        </div>

        <div className='form-group'>
          <label htmlFor='lastName'>姓 *</label>
          <input
            {...register('lastName', {
              required: '姓は必須です',
              minLength: {
                value: 2,
                message: '2文字以上で入力してください',
              },
            })}
            className={errors.lastName ? 'error' : ''}
          />
          {errors.lastName && (
            <span className='error-message'>
              {errors.lastName.message}
            </span>
          )}
        </div>

        <div className='form-group'>
          <label htmlFor='email'>メールアドレス *</label>
          <input
            type='email'
            {...register('email', {
              required: 'メールアドレスは必須です',
              pattern: {
                value:
                  /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
                message:
                  '有効なメールアドレスを入力してください',
              },
            })}
            className={errors.email ? 'error' : ''}
          />
          {errors.email && (
            <span className='error-message'>
              {errors.email.message}
            </span>
          )}
        </div>

        <div className='form-group'>
          <label htmlFor='password'>パスワード *</label>
          <input
            type='password'
            {...register('password', {
              required: 'パスワードは必須です',
              minLength: {
                value: 8,
                message: '8文字以上で入力してください',
              },
              pattern: {
                value: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
                message:
                  '大文字小文字数字を含めてください',
              },
            })}
            className={errors.password ? 'error' : ''}
          />
          {errors.password && (
            <span className='error-message'>
              {errors.password.message}
            </span>
          )}
        </div>

        <div className='form-group'>
          <label htmlFor='confirmPassword'>
            パスワード確認 *
          </label>
          <input
            type='password'
            {...register('confirmPassword', {
              required: 'パスワード確認は必須です',
              validate: (value) =>
                value === password ||
                'パスワードが一致しません',
            })}
            className={
              errors.confirmPassword ? 'error' : ''
            }
          />
          {errors.confirmPassword && (
            <span className='error-message'>
              {errors.confirmPassword.message}
            </span>
          )}
        </div>

        <div className='form-actions'>
          <button
            type='submit'
            disabled={!isValid}
            className='btn-primary'
          >
            次へ進む
          </button>
        </div>
      </form>
    </div>
  );
};

多段階フォームでのデータ引き継ぎ

多段階フォームでは、前のステップで入力されたデータを後続のステップで活用することが重要です。

以下は、プロフィール入力フォームで基本情報を参照する例です。

typescript// components/ProfileForm.tsx
import { useForm } from 'react-hook-form';
import { useRegistrationStore } from '../store/registrationStore';
import { UserRegistration } from '../types/user';

type ProfileFormData = UserRegistration['profile'];

export const ProfileForm = () => {
  const {
    registrationData,
    updateProfile,
    setCurrentStep,
    markStepCompleted,
  } = useRegistrationStore();

  // 基本情報から名前を取得して表示
  const userName = registrationData.basicInfo
    ? `${registrationData.basicInfo.firstName} ${registrationData.basicInfo.lastName}`
    : '';

  const {
    register,
    handleSubmit,
    formState: { errors, isValid },
  } = useForm<ProfileFormData>({
    defaultValues: registrationData.profile || {},
    mode: 'onChange',
  });

  const onSubmit = (data: ProfileFormData) => {
    updateProfile(data);
    markStepCompleted(1);
    setCurrentStep(2); // 次のステップへ
  };

  const goBack = () => {
    setCurrentStep(0); // 前のステップに戻る
  };

  return (
    <div className='form-container'>
      <h2>プロフィール情報の入力</h2>
      {userName && (
        <p className='welcome-message'>
          こんにちは、{userName}
          さん!プロフィール情報を入力してください。
        </p>
      )}

      <form
        onSubmit={handleSubmit(onSubmit)}
        className='registration-form'
      >
        <div className='form-group'>
          <label htmlFor='birthDate'>生年月日</label>
          <input
            type='date'
            {...register('birthDate', {
              required: '生年月日は必須です',
              validate: (value) => {
                const birthDate = new Date(value);
                const today = new Date();
                const age =
                  today.getFullYear() -
                  birthDate.getFullYear();
                return (
                  age >= 18 ||
                  '18歳以上である必要があります'
                );
              },
            })}
            className={errors.birthDate ? 'error' : ''}
          />
          {errors.birthDate && (
            <span className='error-message'>
              {errors.birthDate.message}
            </span>
          )}
        </div>

        <div className='form-group'>
          <label htmlFor='gender'>性別</label>
          <select
            {...register('gender', {
              required: '性別を選択してください',
            })}
            className={errors.gender ? 'error' : ''}
          >
            <option value=''>選択してください</option>
            <option value='male'>男性</option>
            <option value='female'>女性</option>
            <option value='other'>その他</option>
          </select>
          {errors.gender && (
            <span className='error-message'>
              {errors.gender.message}
            </span>
          )}
        </div>

        <div className='form-group'>
          <label htmlFor='occupation'>職業</label>
          <input
            {...register('occupation')}
            placeholder='例:エンジニア、デザイナー'
          />
        </div>

        <div className='form-group'>
          <label htmlFor='bio'>自己紹介</label>
          <textarea
            {...register('bio', {
              maxLength: {
                value: 500,
                message: '500文字以内で入力してください',
              },
            })}
            rows={4}
            placeholder='簡単な自己紹介をお書きください(任意)'
            className={errors.bio ? 'error' : ''}
          />
          {errors.bio && (
            <span className='error-message'>
              {errors.bio.message}
            </span>
          )}
        </div>

        <div className='form-actions'>
          <button
            type='button'
            onClick={goBack}
            className='btn-secondary'
          >
            戻る
          </button>
          <button
            type='submit'
            disabled={!isValid}
            className='btn-primary'
          >
            次へ進む
          </button>
        </div>
      </form>
    </div>
  );
};

フォーム間バリデーション連携

複数のフォームが連携する場合、全体的なバリデーション状態を管理することが重要です。

以下は、進行状況を表示し、全体の完了状態を管理するコンポーネントの例です。

typescript// components/RegistrationProgress.tsx
import { useRegistrationStore } from '../store/registrationStore';

export const RegistrationProgress = () => {
  const {
    currentStep,
    completedSteps,
    registrationData,
    submitRegistration,
    isSubmitting,
    submitError,
  } = useRegistrationStore();

  const steps = [
    { id: 0, title: '基本情報', required: true },
    { id: 1, title: 'プロフィール', required: true },
    { id: 2, title: '設定', required: false },
  ];

  // 必須ステップがすべて完了しているかチェック
  const requiredStepsCompleted = steps
    .filter((step) => step.required)
    .every((step) => completedSteps.has(step.id));

  // 全体の進行率を計算
  const progressPercentage =
    (completedSteps.size / steps.length) * 100;

  const handleFinalSubmit = async () => {
    if (requiredStepsCompleted) {
      await submitRegistration();
    }
  };

  return (
    <div className='registration-progress'>
      <div className='progress-header'>
        <h3>登録進行状況</h3>
        <div className='progress-bar'>
          <div
            className='progress-fill'
            style={{ width: `${progressPercentage}%` }}
          />
        </div>
        <span className='progress-text'>
          {Math.round(progressPercentage)}% 完了
        </span>
      </div>

      <div className='steps-list'>
        {steps.map((step) => (
          <div
            key={step.id}
            className={`step-item ${
              currentStep === step.id ? 'current' : ''
            } ${
              completedSteps.has(step.id) ? 'completed' : ''
            }`}
          >
            <div className='step-indicator'>
              {completedSteps.has(step.id)
                ? '✓'
                : step.id + 1}
            </div>
            <div className='step-content'>
              <span className='step-title'>
                {step.title}
              </span>
              {step.required && (
                <span className='required'>*必須</span>
              )}
            </div>
          </div>
        ))}
      </div>

      {/* データ確認セクション */}
      {completedSteps.size > 0 && (
        <div className='data-summary'>
          <h4>入力済みデータ</h4>
          {registrationData.basicInfo && (
            <div className='summary-section'>
              <h5>基本情報</h5>
              <p>
                名前: {registrationData.basicInfo.firstName}{' '}
                {registrationData.basicInfo.lastName}
              </p>
              <p>
                メール: {registrationData.basicInfo.email}
              </p>
            </div>
          )}
          {registrationData.profile && (
            <div className='summary-section'>
              <h5>プロフィール</h5>
              <p>
                生年月日:{' '}
                {registrationData.profile.birthDate}
              </p>
              <p>性別: {registrationData.profile.gender}</p>
            </div>
          )}
        </div>
      )}

      {/* 最終送信ボタン */}
      {requiredStepsCompleted && (
        <div className='final-submit'>
          <button
            onClick={handleFinalSubmit}
            disabled={isSubmitting}
            className='btn-primary btn-large'
          >
            {isSubmitting ? '登録中...' : '登録を完了する'}
          </button>
          {submitError && (
            <div className='error-message'>
              {submitError}
            </div>
          )}
        </div>
      )}
    </div>
  );
};

メインの登録コンポーネントでは、これらすべてを統合します。

typescript// components/UserRegistration.tsx
import { useRegistrationStore } from '../store/registrationStore';
import { BasicInfoForm } from './BasicInfoForm';
import { ProfileForm } from './ProfileForm';
import { PreferencesForm } from './PreferencesForm';
import { RegistrationProgress } from './RegistrationProgress';

export const UserRegistration = () => {
  const { currentStep } = useRegistrationStore();

  const renderCurrentStep = () => {
    switch (currentStep) {
      case 0:
        return <BasicInfoForm />;
      case 1:
        return <ProfileForm />;
      case 2:
        return <PreferencesForm />;
      default:
        return <BasicInfoForm />;
    }
  };

  return (
    <div className='registration-container'>
      <div className='registration-content'>
        <div className='form-section'>
          {renderCurrentStep()}
        </div>
        <div className='progress-section'>
          <RegistrationProgress />
        </div>
      </div>
    </div>
  );
};

この実装により、各フォームは独立して動作しながら、全体的な状態管理は Zustand で一元化されます。ユーザーは任意のタイミングでページを離れても、入力済みのデータが保持され、後から続きを入力できるようになります。

まとめ

Zustand と React Hook Form の組み合わせは、複雑なフォーム状態管理において非常に強力なソリューションです。

本記事でご紹介したパターンを活用することで、以下のメリットを得られます。

技術的なメリット

  • フォーム固有の状態とアプリケーション全体の状態の適切な分離
  • 最小限の再レンダリングによるパフォーマンス最適化
  • 型安全性を保ちながらの開発効率向上

ユーザー体験の向上

  • 途中保存機能による入力データの保護
  • 段階的な入力プロセスでの使いやすさ
  • リアルタイムバリデーションによる即座のフィードバック

開発・保守性の向上

  • カスタムフックによるロジックの再利用
  • 明確な責任分離による保守性の向上
  • テストしやすい構造の実現

実際のプロジェクトでこれらのパターンを適用する際は、アプリケーションの要件に応じて適切にカスタマイズしてください。特に、永続化の範囲やバリデーションルールについては、セキュリティとユーザビリティのバランスを考慮することが重要です。

Zustand と React Hook Form の組み合わせにより、ユーザーにとって使いやすく、開発者にとって保守しやすいフォームシステムを構築できるでしょう。

関連リンク