فرم‌های طولانی و پیچیده می‌توانند به‌راحتی باعث سردرگمی کاربران شوند و در نهایت آن‌ها را از ادامه مسیر بازدارند. در بسیاری از اپلیکیشن‌هایی که توسعه می‌دهیم، نیاز داریم حجم قابل‌توجهی از اطلاعات را از کاربران دریافت کنیم؛ چه در مراحل ورود و ثبت‌نام، چه فرآیند پرداخت یا انجام نظرسنجی‌ها. بنابراین در چنین مواردی، استفاده از فرم چند مرحله‌ای می‌تواند راهکاری مؤثر برای ساده‌سازی تجربه کاربری باشد.

در این مقاله، فرآیند ساخت یک کامپوننت فرم چند مرحله‌ای قابل استفاده مجدد در React را با استفاده از کتابخانه‌های React Hook Form و Zod برای اعتبارسنجی، مرحله‌به‌مرحله بررسی می‌کنیم. این کامپوننت مسئول اعتبارسنجی ورودی‌ها، مدیریت مراحل فرم و ذخیره‌سازی داده‌ها به‌صورت پایدار است تا از نارضایتی کاربران و از بین رفتن اطلاعات جلوگیری شود.

سورس‌کد این پروژه از طریق repository گیت‌هاب قابل دریافت است و دموی live آن نیز در این لینک قابل مشاهده می‌باشد.

پیش‌نیازها

برای دنبال کردن این آموزش، بهتر است با موارد زیر آشنایی داشته باشیم:

آماده‌سازی محیط توسعه برای فرم چند مرحله‌ای

در این پروژه از پکیج‌های زیر برای پیاده‌سازی استفاده خواهیم کرد:

ترمینال خود را در مسیر دلخواه باز کرده و برای ایجاد یک پروژه جدید React با Vite و تایپ اسکریپت، دستور زیر را اجرا می‌کنیم:

pnpm create vite@latest multi-step-form
# Select React + TypeScript & SWC to follow along

سپس وارد پوشه پروژه شده و پکیج‌های موردنیاز را نصب می‌کنیم:

cd multi-step-form
pnpm install && pnpm add react-hook-form react-router-dom zod @mantine/hooks framer-motion lucide-react

در این مرحله، زیرساخت اصلی پروژه آماده است. در گام بعد، Tailwind را نصب کرده و shadcn را در پروژه مقداردهی اولیه می‌کنیم.

نصب Tailwind و shadcn

همان‌طور که در بخش قبل اشاره شد، در این پروژه از shadcn که مجموعه‌ای متن‌باز از کامپوننت‌های UI است، برای طراحی چیدمان فرم استفاده می‌کنیم. این کار به ما اجازه می‌دهد تا تمرکز بیشتری روی پیاده‌سازی منطق فرم داشته باشیم. مطالعه مستندات رسمی در این زمینه می‌تواند مفید باشد.

برای نصب و مقداردهی اولیه Tailwind، دستور زیر را اجرا می‌کنیم:

pnpm add -D tailwindcss postcss autoprefixer

سپس برای ایجاد فایل‌های پیکربندی tailwind.config.js و postcss.config.js، دستور زیر را وارد می‌نماییم:

pnpm tailwindcss init -p

پس از ایجاد فایل‌های پیکربندی، دستورات پایه Tailwind را به فایل استایل اصلی پروژه (مثلاً src/index.css) اضافه می‌کنیم:

@tailwind base;
@tailwind components;
@tailwind utilities;

/* your custom css here */

در گام بعد، فایل tailwind.config.js را به‌روزرسانی می‌کنیم تا مسیر فایل‌های محتوای پروژه را مشخص نماییم. این کار به Tailwind کمک می‌کند تا استایل‌های بلااستفاده را در حالت production حذف کند:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"],
  theme: {
    extend: {},
  },
  plugins: [],
};

برای بهبود فرآیند resolution ماژول‌ها، فایل tsconfig.json را طوری پیکربندی می‌کنیم که یک alias برای دایرکتوری src تعریف شود. این کار موجب ساده‌تر شدن importها در سراسر پروژه خواهد شد:

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

همچنین چون Vite به‌صورت پیش‌فرض دارای فایل tsconfig.app.json است، همان پیکربندی را در آن نیز اعمال می‌کنیم:

// tsconfig.app.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

سپس باید پیکربندی Vite را طوری تنظیم کنیم که alias تعریف شده را بشناسد. برای این منظور فایل vite.config.ts را باز کرده و تغییرات لازم را اعمال می‌کنیم:

// vite.config.ts
import path from 'path'
import { defineConfig } from 'vite'
import { fileURLToPath } from 'url'
import react from '@vitejs/plugin-react-swc'

const __dirname = fileURLToPath(new URL('.', import.meta.url))

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
})

اکنون که Tailwind به‌درستی تنظیم شده است، نوبت به راه‌اندازی shadcn می‌رسد. برای مقداردهی اولیه آن، دستور زیر را اجرا می‌کنیم:

pnpm dlx shadcn@latest init -d

در جریان مقداردهی اولیه، shadcn بررسی‌هایی را انجام می‌دهد، فریم‌ورک ما را اعتبارسنجی کرده، تنظیمات Tailwind را پیکربندی می‌کند و فایل‌های پروژه را به‌روزرسانی خواهد کرد. پس از اتمام این فرآیند، خروجی‌ای مشابه نمونه زیر مشاهده خواهیم کرد:

✔ Preflight checks.
✔ Verifying framework. Found Vite.
✔ Validating Tailwind CSS.
✔ Validating import alias.
✔ Writing components.json.
✔ Checking registry.
✔ Updating tailwind.config.ts
✔ Updating app\app.css
✔ Installing dependencies.
✔ Created 1 file:
  - app\lib\utils.ts

Success! Project initialization completed.
You may now add components.

اکنون Tailwind و shadcn به‌طور کامل در پروژه Vite + React + TypeScript ما راه‌اندازی شده‌اند.

در این مرحله، برخی کامپوننت‌هایی که در ادامه پروژه به آن‌ها نیاز داریم، نظیر input، button، form، toast و label را نصب می‌کنیم. برای این کار دستور زیر را اجرا می‌کنیم:

pnpm dlx shadcn@latest add input button form label toast

نکته‌ای که باید به آن توجه داشته باشیم این است که نصب کتابخانه فرم shadcn به‌طور خودکار پکیج React Hook Form را نیز نصب خواهد کرد.

تعریف تایپ  FormStep، طرح schema و داده‌ها

باید این موضوع را به خاطر داشته باشیم که هدف ما در اینجا قابلیت استفاده مجدد است. ابتدا با تعریف تایپ FormStep شروع می‌کنیم. این تایپ شامل ویژگی‌های مورد نیاز هر مرحله جدید از فرم خواهد بود، مانند عنوان مرحله، موقعیت آن در فرم، schema برای اعتبارسنجی و کامپوننت مرتبط با آن. البته می‌توانیم این ساختار را با توجه به نیازهای خود گسترش دهیم.

در ابتدا، تایپ FormStep را در فایل src/types.ts تعریف می‌کنیم. این تایپ، نمایانگر یک مرحله مستقل در فرم است:

// src/types.ts
import { ZodType } from 'zod';
import { CombinedCheckoutType } from './validators/checkout-flow.validator';
import { LucideIcon } from 'lucide-react';

type FieldKeys = keyof CombinedCheckoutType;

export type FormStep = {
  title: string;
  position: number;
  validationSchema: ZodType<unknown>;
  component: React.ReactElement;
  icon: LucideIcon;
  fields: FieldKeys[];
};

در ادامه، توضیح مختصری درباره هر یک از ویژگی‌های نوع FormStep ارائه شده است:

با دیدن پیاده‌سازی این ساختار، درک بهتری از عملکرد آن پیدا خواهیم کرد.

از آنجا که هدف ما شبیه‌سازی یک فرآیند تسویه‌حساب است، ابتدا schemaهای اعتبارسنجی مربوط به هر مرحله را در مسیر src/validators/checkout-flow.validator.ts تعریف می‌کنیم:

// src/validators/checkout-flow.validator.ts
import { z } from 'zod'

export const step1Schema = z.object({
  email: z.string().email({ message: 'Please enter a valid email address' }),
  firstName: z.string().min(3, 'First name must be at least 3 characters'),
  lastName: z.string().min(3, 'Last name must be at least 3 characters'),
})
export const step2Schema = z.object({
  country: z
    .string()
    .min(2, 'Country must be at least 2 characters')
    .max(100, 'Country must be less than 100 characters'),
  city: z
    .string()
    .min(2, 'City must be at least 2 characters')
  /* ... more fields ... */
})
export const step3Schema = z.object({
  /* ... cardNumber, carrdHolder, cvv ... */
})

برای اینکه فرم ما type-safe باقی بماند و همچنین بتوانیم از schemaها به‌صورت مجدد استفاده کنیم، مجموعه‌ای از این schemaها را در قالب یک schema واحد ادغام می‌کنیم:

export const CombinedCheckoutSchema= step1Schema
  .merge(step2Schema)
  .merge(step3Schema)

export type CombinedCheckoutType = z.infer<typeof CombinedCheckoutSchema>

با ادغام schemaها، تعریف تمام فیلدهای مراحل مختلف در یک schema مادر تجمیع می‌شود. سپس می‌توانیم از این schema ترکیبی، یک نوع جدید به نام CombinedCheckoutSchema استخراج کنیم که تمام فیلدهای فرم چند مرحله‌ای را شامل می‌شود. این schema ترکیبی، در زمان استفاده از React Hook Form نیز بسیار مفید خواهد بود.

در مرحله پایانی این بخش، آرایه‌ای به نام checkoutSteps در مسیر src/pages/home.tsx تعریف می‌کنیم که نمایانگر مراحل فرم ما است:

import { FormStep } from '@/types'
import Step1 from './checkout/step1'
import Step2 from './checkout/step2'
import Step3 from './checkout/step3'
import {
  step1Schema,
  step2Schema,
  step3Schema,
} from '@/validators/checkout-flow.validator'
import MultiStepForm from '@/components/stepped-form/stepped-form'
import { HomeIcon, UserIcon, CreditCardIcon } from 'lucide-react'

export const checkoutSteps: FormStep[] = [
  {
    title: 'Step 1: Personal Information',
    component: <Step1 />,
    icon: UserIcon,
    position: 1,
    validationSchema: step1Schema,
    fields: ['email', 'firstName', 'lastName'],
  },
  {
    title: 'Step 2: Address Details',
    component: <Step2 />,
    icon: HomeIcon,
    position: 2,
    validationSchema: step2Schema,
    fields: ['country', 'city', 'shippingAddress'],
  },
  {
    title: 'Step 3: Payment Details',
    component: <Step3 />,
    icon: CreditCardIcon,
    position: 3,
    validationSchema: step3Schema,
    fields: ['cardNumber', 'cardholderName', 'cvv'],
  },
]

export default function Home() {
  return (
    <div>
      <MultiStepForm steps={checkoutSteps} />
    </div>
  )
}

با انجام این تنظیمات، اکنون آماده‌ایم تا کامپوننت SteppedForm را پیاده‌سازی کنیم؛ این کامپوننت به‌صورت داینامیک فرآیند رندر فرم، مدیریت state، منطق فرم و اعتبارسنجی را با استفاده از داده‌های موجود در checkoutSteps بر عهده خواهد گرفت.

ساخت کامپوننت SteppedForm

کامپوننت SteppedForm ستون فقرات طراحی فرم چند مرحله‌ای ما است. این کامپوننت منطق فرم را پیاده‌سازی می‌کند، مرحله فعلی را ردیابی کرده، ورودی‌ها را اعتبارسنجی می‌نماید و توابعی برای پیمایش بین مراحل فراهم می‌کند.

در زمان ساخت این کامپوننت، به چند سؤال کلیدی فکر می‌کنیم:

مواردی مانند currentStep، isFirstStep، isLastStep و توابع controller مانند nextStep و previousStep از جمله مواردی هستند که برای عملکرد صحیح فرم چند مرحله‌ای به آن‌ها نیاز داریم.

کتابخانه React Hook Form از React Context استفاده می‌کند. این ویژگی به ما اجازه می‌دهد تا state فرم را در میان کامپوننت‌ها به اشتراک بگذاریم؛ کافی است که از یک کامپوننت parent به نام <FormProvider /> استفاده کنیم. در این صورت، هر کامپوننت childای می‌تواند بدون نیاز به ارسال prop، به state فرم دسترسی داشته باشد.

علاوه بر این، قصد داریم یک هوک سفارشی برای مدیریت state فرم داشته باشیم، چیزی شبیه به این:

const { isFirstStep, isLastStep, nextStep } = useMultiStepForm();

ساده‌ترین راه برای رسیدن به این هدف، استفاده هم‌زمان از دو مقدار context است: یکی از API خود React Hook Form و دیگری از هوک سفارشی useMultiStepForm.

این تفکیک، باعث می‌شود منطق فرم واضح باقی بماند و در عین حال دسترسی به state فرم و امکان پیمایش بین مراحل به‌سادگی فراهم شود.

چرا از Context در فرم‌های چند مرحله‌ای استفاده می‌کنیم؟

Context API ری‌اکت به ما این امکان را می‌دهد که state و منطق فرم را به‌راحتی به اشتراک بگذاریم و از ارسال prop بین لایه‌های مختلف اجتناب کنیم. Context شامل تمام stateها و متدهای حیاتی است که مراحل فرم، دکمه‌های navigation و کامپوننت‌های نمایش پیشرفت به آن‌ها نیاز دارند.

در حال حاضر، Context ما شامل موارد زیر است:

export interface MultiStepFormContextProps {
  currentStep: FormStep;
  currentStepIndex: number;
  isFirstStep: boolean;
  isLastStep: boolean;
  nextStep: () => void;
  previousStep: () => void;
  goToStep: (step: number) => void;
  steps: FormStep[];
}

با در دسترس قرار دادن این ویژگی‌ها و متدها در context، فرم ما قابل پیکربندی و استفاده در هر کامپوننت child خواهد بود.

ساخت کامپوننت SteppedForm

در این بخش، فرآیند ساخت کامپوننت SteppedForm را قدم‌به‌قدم بررسی می‌کنیم. ابتدا، context موردنیاز برای مدیریت state فرم و جابه‌جایی بین مراحل را تعریف می‌کنیم. سپس ساختار کلی فرم را با استفاده از کتابخانه React Hook Form پیاده‌سازی خواهیم کرد.

در پایان این بخش، ما یک کامپوننت چند مرحله‌ای کاربردی خواهیم داشت که قابل توسعه با قابلیت‌هایی مثل دکمه‌های navigation، نمایش میزان پیشرفت و سایر ویژگی‌های موردنظر است.

اکنون ساخت کامپوننت SteppedForm را آغاز می‌کنیم:

// components/stepped-form/stepped-form.tsx
import { z } from 'zod';
import { createContext, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { FormStep, MultiStepFormContextProps } from '@/types';
import { zodResolver } from '@hookform/resolvers/zod';
import { CombinedCheckoutSchema } from '@/validators/checkout-flow.validator';
import PrevButton from '@/components/stepped-form/prev-button';
import ProgressIndicator from './progress-indicator';

export const MultiStepFormContext = createContext<MultiStepFormContextProps | null>(null);

const MultiStepForm = ({ steps }: { steps: FormStep[] }) => {
  const methods = useForm<z.infer<typeof CombinedCheckoutSchema>>({
    resolver: zodResolver(CombinedCheckoutSchema),
  });

  // Form state
  const [currentStepIndex, setCurrentStepIndex] = useState(0);
  const currentStep = steps[currentStepIndex];

  // Navigation functions
  const nextStep = () => {
    if (currentStepIndex < steps.length - 1) {
      setCurrentStepIndex(currentStepIndex + 1);
    }
  };

  const previousStep = () => {
    if (currentStepIndex > 0) {
      setCurrentStepIndex(currentStepIndex - 1);
    }
  };

 const goToStep = (position: number) => {
    if (position >= 0 && position - 1 < steps.length) {
      setCurrentStepIndex(position - 1)
      saveFormState(position - 1)
    }
  }

  /* Form submission function */
  async function submitSteppedForm(data: z.infer<typeof CombinedCheckoutSchema>) {
    try {
      // Perform your form submission logic here
      console.log('data', data);
    } catch (error) {
      console.error('Form submission error:', error);
    }
  }

  // Context value
  const value: MultiStepFormContextProps = {
    currentStep: steps[currentStepIndex],
    currentStepIndex,
    isFirstStep: currentStepIndex === 0,
    isLastStep: currentStepIndex === steps.length - 1,
    goToStep,
    nextStep,
    previousStep,
    steps,
  };

  return (
    <MultiStepFormContext.Provider value={value}>
      <FormProvider {...methods}>
        <div className="w-[550px] mx-auto">
          <ProgressIndicator />
          <form onSubmit={methods.handleSubmit(submitSteppedForm)}>
            <h1 className="py-5 text-3xl font-bold">{currentStep.title}</h1>
            {currentStep.component}
            <PrevButton />
          </form>
        </div>
      </FormProvider>
    </MultiStepFormContext.Provider>
  );
};

export default MultiStepForm;

از آنجایی که در این قسمت جزئیات زیادی وجود دارد، آن‌ها را به‌صورت موردی بررسی می‌کنیم:

۱. FormProvider و Context چند مرحله‌ای

همان‌طور که پیش‌تر اشاره شد، FormProvider در React Hook Form به ما این امکان را می‌دهد که متدهای فرم را در اختیار تمام کامپوننت‌های child قرار دهیم. به‌این‌ترتیب، می‌توانیم state فرم و اعتبارسنجی آن را در چند مرحله با استفاده از هوک useFormContext به‌جای useForm مدیریت کنیم.

MultiStepFormContext نیز state و توابع لازم برای جابه‌جایی بین مراحل را در اختیار تمام کامپوننت‌های child قرار می‌دهد؛ بنابراین، دکمه‌ها و کامپوننت‌های نشان‌دهنده پیشرفت می‌توانند با state فرم تعامل داشته باشند.

۲. المنت اصلی فرم و ساختار کلی

المنت form باید تمام مراحل فرم چند مرحله‌ای را دربر گیرد. این موضوع اهمیت زیادی دارد، زیرا قرار دادن المنت‌های form به‌صورت تودرتو در هر مرحله می‌تواند موجب بروز خطا شود.

همچنین باید توجه داشته باشیم که هر <button> درون این فرم که نوع آن type="submit" باشد (که حالت پیش‌فرض است)، باعث ارسال فرم خواهد شد. برای جلوگیری از ارسال زودهنگام، تنها در آخرین مرحله از دکمه‌ای با این ویژگی استفاده می‌کنیم، که در ادامه به‌طور کامل به آن خواهیم پرداخت.

۳. رندر مرحله جاری و مقداردهی اولیه فرم

مرحله مناسب از طریق مقدار کامپوننت currentStep.component رندر می‌شود.

فرم با استفاده از هوک useForm از React Hook Form مقداردهی اولیه می‌شود و برای اعتبارسنجی از schema  ترکیبی CombinedCheckoutSchema استفاده می‌کنیم. وجود zodResolver باعث می‌شود داده‌های فرم پیش از ارسال، در برابر schema مورد بررسی و اعتبارسنجی قرار گیرند.

۴. ارسال نهایی فرم

تابع submitSteppedForm مسئول ارسال نهایی فرم است. در حال حاضر، این تابع صرفاً داده‌های فرم را در کنسول چاپ می‌کند؛ اما در استفاده واقعی می‌توان آن را با منطق ارسال داده به API یا سرور جایگزین کرد.

۵. پیاده‌سازی توابع navigation بین مراحل

توابع nextStep، previousStep و goToStep امکان جابه‌جایی بین مراحل را فراهم می‌کنند. این توابع از طریق Context در اختیار کامپوننت‌های دیگر مانند PrevButton، NextButton و ProgressIndicator قرار می‌گیرند.

با وجود این ساختار پایه، اطمینان داریم که کامپوننت SteppedForm هم قابل استفاده مجدد بوده و هم به‌خوبی ایزوله است؛ به‌گونه‌ای که فقط با کامپوننت‌های موردنیاز خود state را به اشتراک می‌گذارد.

اکنون می‌توانیم یک تابع useMultiStep نیز تعریف و export کنیم تا در سایر کامپوننت‌های child مورد استفاده قرار گیرد:

// src/hooks/use-stepped-form.ts
import { MultiStepFormContext } from '@/components/stepped-form/stepped-form'
import { useContext } from 'react'

export const useMultiStepForm = () => {
  const context = useContext(MultiStepFormContext)
  if (!context) {
    throw new Error(
      'useMultiStepForm must be used within MultiStepForm.Provider'
    )
  }
  return context
}

اعتبارسنجی ورودی‌ها در تابع nextStep

تابع nextStep مسئول جابه‌جایی مرحله‌ای فرم است. با این حال، می‌خواهیم قبل از تغییر مرحله، اعتبارسنجی فیلدهای همان مرحله انجام شود؛ بنابراین، این تابع را کمی اصلاح خواهیم کرد:

const nextStep = async () => {
  const isValid = await methods.trigger(currentStep.fields);

  if (!isValid) {
    return; // Stop progression if validation fails
  }

  // grab values in current step and transform array to object
  const currentStepValues = methods.getValues(currentStep.fields)
  const formValues = Object.fromEntries(
    currentStep.fields.map((field, index) => [
      field,
      currentStepValues[index] || '',
    ])
  )

  // Validate the form state against the current step's schema
  if (currentStep.validationSchema) {
    const validationResult = currentStep.validationSchema.safeParse(formValues);

    if (!validationResult.success) {
      validationResult.error.errors.forEach((err) => {
        methods.setError(err.path.join('.') as keyof SteppedFlowType, {
          type: 'manual',
          message: err.message,
        });
      });
      return; // Stop progression if schema validation fails
    }
  }

  // Move to the next step if not at the last step
  if (currentStepIndex < steps.length - 1) {
    setCurrentStepIndex(currentStepIndex + 1);
  }
};

در ادامه، جریان عملکرد این تابع را بررسی می‌کنیم:

۱. اجرای اعتبارسنجی اولیه مرحله جاری

اولین گام در این تابع، اجرای تابع methods.trigger از React Hook Form است که مسئول اعتبارسنجی فیلدهای مرحله جاری است.

۲. دریافت مقادیر مرحله جاری و تبدیل آن‌ها به آبجکت

سپس مقادیر فیلدهای مرحله جاری را با استفاده از methods.getValues(currentStep.fields) دریافت می‌کنیم. این تابع مقدارها را به‌صورت آرایه return می‌کند؛ مانند: ['test@test.com', 'John', 'Doe']

برای آن‌که بتوانیم این آرایه را به آبجکتی قابل اعتبارسنجی تبدیل کنیم، از Object.fromEntries استفاده می‌کنیم. نتیجه تبدیل به شکلی خواهد بود مانند: { email: 'test@test.com', firstName: 'John', lastName: 'Doe' }

۳. اعتبارسنجی از طریق schema

اکنون که داده‌ها به فرمت صحیح تبدیل شده‌اند، آن‌ها را در برابر currentStep.validationSchem مرحله جاری بررسی می‌کنیم. اگر خطایی وجود داشته باشد، با استفاده از methods.setError گزارش می‌شود.

۴. جابه‌جایی به مرحله بعد در صورت موفقیت

در نهایت، اگر تمام اعتبارسنجی‌ها با موفقیت انجام شوند، کاربر به مرحله بعد منتقل خواهد شد.

دکمه‌های navigation در فرم چندمرحله‌ای: قبلی و بعدی

حالا که کامپوننت SteppedForm را با توابع navigation مناسب راه‌اندازی کرده‌ایم، می‌توانیم از آن‌ها در دکمه‌های سفارشی مانند NextButton و PreviousButton یا کامپوننت نمایش پیشرفت استفاده کنیم. ابتدا با دکمه PrevButton شروع می‌کنیم:

// prevbutton.tsx
import { useMultiStepForm } from '@/hooks/use-stepped-form'
import { Button } from '../ui/button'

const PrevButton = () => {
  const { isFirstStep, previousStep } = useMultiStepForm()

  return (
    <Button
      variant='outline'
      type='button'
      className='mt-5'
      onClick={previousStep}
      disabled={isFirstStep}
    >
      Previous
    </Button>
  )
}
export default PrevButton

و سپس دکمه NextButton:

// nextbutton.tsx
const NextButton = ({
  onClick,
  type,
  ...rest
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
  const { isLastStep } = useMultiStepForm()

  return (
    <Button
      className="text-white bg-black hover:bg-slate-950 transition-colors w-full py-6"
      type={type ?? 'button'}
      onClick={onClick}
      {...rest}
    >
      {isLastStep ? 'Submit' : 'Continue'}
    </Button>
  )
}

باید این نکته را به یاد داشته باشیم که در طراحی فرم ما، تنها یک دکمه باید ویژگی type="submit" را داشته باشد. دکمه NextButton در واقع دو نقش ایفا می‌کند:

ساخت کامپوننت‌های اختصاصی برای هر مرحله

هر مرحله از فرم یک کامپوننت مستقل است که از الگوی مشخصی پیروی می‌کند:

  1. اعتبارسنجی ورودی‌ها بر اساس schema مشخص شده
  2. در صورت نیاز، انجام عملیات اضافی روی داده‌ها، مانند بررسی صحت ایمیل در پایگاه داده
  3. فراخوانی تابع nextStep از هوک useMultiStepForm برای رفتن به مرحله بعد

بیایید نگاهی به مرحله اول یعنی Step1 داشته باشیم:

const Step1 = () => {
  const {
    register,
    getValues,
    setError,
    formState: { errors },
  } = useFormContext<z.infer<typeof SteppedFlowSchema>>()

  const { nextStep } = useMultiStepForm()

  const handleStepSubmit = async () => {
    const { email } = getValues()

    // Simulate check for existing email in the database
    if (email === 'test@test.com') {
      setError('email', {
        type: 'manual',
        message: 'Email already exists in the database. Please use a different email.',
      })
      return
    }

    // move to the next step
    nextStep()
  }

  return (
    <div className="flex flex-col gap-3">
      <div>
        <Input {...register('email')} placeholder="Email" />
        <ErrorMessage message={errors.email?.message} />
      </div>
      <NextButton onClick={handleStepSubmit} />
    </div>
  )
}

در این مرحله، پیش از اجرای nextStep تصمیم داریم یک درخواست (شبیه‌سازی شده) به پایگاه داده ارسال کنیم. این الگو برای مراحل دوم نیز ادامه پیدا می‌کند.

در مرحله پایانی، یعنی Step3، به‌صورت صریح نوع دکمه navigation را برابر submit قرار می‌دهیم:

const Step3 = () => {
  /* ... */
  const handleStepSubmit = async () => {
    return
  }

  return (
    <div className="flex flex-col gap-3">
      {/* Form fields here */}
      <NextButton type="submit" onClick={handleStepSubmit} />
    </div>
  )
}

پیاده‌سازی نمایشگر پیشرفت فرم چند مرحله‌ای

نمایش بازخورد بصری از میزان پیشرفت کاربر در فرم‌ها، به‌طور معمول یک راهکار خوب و حرفه‌ای محسوب می‌شود. این کار باعث می‌شود کاربر احساس سردرگمی یا خستگی نداشته باشد.

ما این موضوع را با ساخت یک کامپوننت نمایشگر پیشرفت پیاده‌سازی می‌کنیم، که با ابزار v0 تولید شده است:

// progress-indicator.tsx
export default function ProgressIndicator() {
  const { currentStep, goToStep, currentStepIndex } = useMultiStepForm()

  return (
    <div className="flex items-center w-full justify-center p-4 mb-10">
      <div className="w-full space-y-8">
        <div className="relative flex justify-between">
          {/* Progress Line */}
          <div className="absolute left-0 top-1/2 h-0.5 w-full -translate-y-1/2 bg-gray-200">
            <motion.div
              className="h-full bg-black"
              initial={{ width: '0%' }}
              animate={{
                width: `${(currentStepIndex / (checkoutSteps.length - 1)) * 100}%`,
              }}
              transition={{ duration: 0.3, ease: 'easeInOut' }}
            />
          </div>
          {/* Steps */}
          {checkoutSteps.map((step) => {
            const isCompleted = currentStepIndex > step.position - 1
            const isCurrent = currentStepIndex === step.position - 1

            return (
              <div key={step.position} className="relative z-10">
                <motion.button
                  onClick={() => goToStep(step.position)}
                  className={`flex size-14 items-center justify-center rounded-full border-2 ${
                    isCompleted || isCurrent
                      ? 'border-primary bg-black text-white'
                      : 'border-gray-200 bg-white text-gray-400'
                  }`}
                  animate={{
                    scale: isCurrent ? 1.1 : 1,
                  }}
                >
                  {isCompleted ? (
                    <Check className="h-6 w-6" />
                  ) : (
                    <step.icon className="h-6 w-6" />
                  )}
                </motion.button>
              </div>
            )
          })}
        </div>
      </div>
    </div>
  )
}

این کامپوننت از مقدار currentStepIndex استفاده می‌کند تا عرض خط پیشرفت را محاسبه کرده و مرحله جاری را برجسته کند.

ذخیره‌سازی state فرم در localStorage

از دست رفتن اطلاعات فرم، اغلب باعث نارضایتی کاربران می‌شود. این اتفاق به‌قدری آزاردهنده است که بسیاری از کاربران فرآیند را نیمه‌کاره رها می‌کنند، که در یک وب‌سایت تجاری می‌تواند به معنی از دست رفتن درآمد باشد.

برای حل این مشکل، فرم را طوری طراحی می‌کنیم که state آن در localStorage مرورگر ذخیره شود.

اما ابتدا، ساختار داده‌هایی که می‌خواهیم ذخیره کنیم به چه صورت خواهد بود؟

type StoredFormState = {
  currentStepIndex: number
  formValues: Record<string, unknown>
}

علاوه‌بر ذخیره state فیلدهای فرم، شماره مرحله جاری (ایندکس مرحله) را هم ذخیره می‌کنیم تا کاربر بتواند دقیقاً از همان جایی که متوقف شده ادامه دهد.

مقداردهی اولیه state فرم در localStorage

ابتدا، state ذخیره شده فرم را از localStorage در کامپوننت MultiStepForm مقداردهی اولیه می‌کنیم. برای اطمینان از قابلیت استفاده مجدد، از کامپوننت می‌خواهیم که prop‌ای به نام localStorageKey دریافت کند. این کار از بروز تداخل زمانی که چندین فرم چند مرحله‌ای در یک برنامه وجود دارند جلوگیری می‌کند.

با استفاده از هوک useLocalStorage از کتابخانه Mantine، یک state لوکال مبتنی بر localStorage ایجاد می‌کنیم که پیشرفت فرم را ذخیره می‌کند:

// stepped-form.tsx
const [savedFormState, setSavedFormState] = useLocalStorage<SavedFormState | null>({
  key: localStorageKey,
  defaultValue: null,
})

اگر state ذخیره شده‌ای از قبل وجود داشته باشد، هنگام تغییر کردن کامپوننت MultiStepForm، آن را با استفاده از methods.reset() متعلق به React Hook Form بازیابی می‌کنیم:

// stepped-form.tsx
useEffect(() => {
  if (savedFormState) {
    setCurrentStepIndex(savedFormState.currentStepIndex)
    methods.reset(savedFormState.formValues)
  }
}, [methods, savedFormState])

این روش کمک می‌کند تا کاربر پس از رفرش صفحه یا بازگشت مجدد، فرم را از همان نقطه قبلی ادامه دهد.

ذخیره‌سازی state فرم پس از هر مرحله

در مرحله بعد، تابعی برای ذخیره state فرم در localStorage تعریف می‌کنیم:

// stepped-form.tsx
const saveFormState = (stepIndex: number) => {
  setSavedFormState({
    currentStepIndex: stepIndex ?? currentStepIndex,
    formValues: methods.getValues(),
  });
};

در React، به‌روزرسانی state به‌صورت asynchronous انجام می‌شود. یعنی وقتی کاربر به مرحله جدیدی می‌رود، مقدار currentStepIndex بعد از انجام navigation به‌روزرسانی می‌شود. اگر state فرم را با استفاده از مقدار قبلی currentStepIndex ذخیره کنیم، مرحله اشتباهی ذخیره می‌شود.

برای مثال:

برای جلوگیری از این مشکل، مقدار مرحله بعدی را به‌صورت صریح به تابع ذخیره‌سازی پاس می‌دهیم.

پاک‌سازی کامل state فرم پس از ارسال موفق

وقتی فرم با موفقیت ارسال می‌شود یا کاربر می‌خواهد فرم را از ابتدا شروع کند، باید localStorage را پاک کنیم:

const clearFormState = () => {
  methods.reset();
  setCurrentStepIndex(0);
  setSavedFormState(null);
  window.localStorage.removeItem(localStorageKey);
};

این مرحله کاملاً ساده است: آیتم مربوط به localStorage را به‌طور کامل حذف می‌کنیم.

حالا می‌توانیم این توابع را در توابع navigation و پیش از انجام navigation به کار ببریم:

// stepped-form.tsx
const nextStep = async () => {
  /* ... */
  if (currentStepIndex < steps.length - 1) {
    saveFormState(currentStepIndex + 1)
    setCurrentStepIndex(currentStepIndex + 1)
  }
}

const previousStep = () => {
  /* ... */
  if (currentStepIndex > 0) {
    saveFormState(currentStepIndex - 1)
    setCurrentStepIndex(currentStepIndex - 1)
  }
}

const goToStep = (position: number) => {
  if (position >= 0 && position - 1 < steps.length) {
    saveFormState(position - 1)
    setCurrentStepIndex(position - 1)
  }
}

این کار تضمین می‌کند که هر بار کاربر بین مراحل جابه‌جا می‌شود، پیشرفت او بلافاصله ذخیره شود.

جمع‌بندی

ما یک کامپوننت فرم چند مرحله‌ای قابل استفاده مجدد، type-safe و مدرن ساختیم که:

معماری این کامپوننت به‌گونه‌ای است که به‌راحتی می‌توان مراحل جدید اضافه کرد یا مراحل موجود را تغییر داد، بدون اینکه منطق اصلی فرم تغییر کند.