فرمهای طولانی و پیچیده میتوانند بهراحتی باعث سردرگمی کاربران شوند و در نهایت آنها را از ادامه مسیر بازدارند. در بسیاری از اپلیکیشنهایی که توسعه میدهیم، نیاز داریم حجم قابلتوجهی از اطلاعات را از کاربران دریافت کنیم؛ چه در مراحل ورود و ثبتنام، چه فرآیند پرداخت یا انجام نظرسنجیها. بنابراین در چنین مواردی، استفاده از فرم چند مرحلهای میتواند راهکاری مؤثر برای سادهسازی تجربه کاربری باشد.
در این مقاله، فرآیند ساخت یک کامپوننت فرم چند مرحلهای قابل استفاده مجدد در React را با استفاده از کتابخانههای React Hook Form و Zod برای اعتبارسنجی، مرحلهبهمرحله بررسی میکنیم. این کامپوننت مسئول اعتبارسنجی ورودیها، مدیریت مراحل فرم و ذخیرهسازی دادهها بهصورت پایدار است تا از نارضایتی کاربران و از بین رفتن اطلاعات جلوگیری شود.
سورسکد این پروژه از طریق repository گیتهاب قابل دریافت است و دموی live آن نیز در این لینک قابل مشاهده میباشد.
برای دنبال کردن این آموزش، بهتر است با موارد زیر آشنایی داشته باشیم:
React.Context
در این پروژه از پکیجهای زیر برای پیادهسازی استفاده خواهیم کرد:
ترمینال خود را در مسیر دلخواه باز کرده و برای ایجاد یک پروژه جدید 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 را در پروژه مقداردهی اولیه میکنیم.
همانطور که در بخش قبل اشاره شد، در این پروژه از 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
را در فایل 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
ارائه شده است:
title
: عنوان مرحلهposition
: ترتیب مرحله در کل فرآیندvalidationSchema
: یک آبجکت از نوع Zod که برای اعتبارسنجی فیلدهای فرم در این مرحله استفاده میشودcomponent
: کامپوننت React که برای نمایش این مرحله رندر خواهد شدicon
: یک آیکون از مجموعه Lucide که برای نمایش بصری مرحله استفاده میشودfields
: آرایهای از رشتهها که هر المنت آن با یکی از کلیدهای موجود در schema تطابق دارد (مثلاً نام فیلدهای input)، و با این کار تایپ فرم تقویت شده و احتمال بروز خطا کاهش مییابدبا دیدن پیادهسازی این ساختار، درک بهتری از عملکرد آن پیدا خواهیم کرد.
از آنجا که هدف ما شبیهسازی یک فرآیند تسویهحساب است، ابتدا 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
ستون فقرات طراحی فرم چند مرحلهای ما است. این کامپوننت منطق فرم را پیادهسازی میکند، مرحله فعلی را ردیابی کرده، ورودیها را اعتبارسنجی مینماید و توابعی برای پیمایش بین مراحل فراهم میکند.
در زمان ساخت این کامپوننت، به چند سؤال کلیدی فکر میکنیم:
مواردی مانند 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 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[]; }
currentStep
: مرحله فعلی فرم که در حال نمایش استcurrentStepIndex
: ایندکس مرحله جاری در آرایه steps
isFirstStep
/ isLastStep
: مقادیر boolean برای تشخیص اینکه آیا کاربر در مرحله اول یا آخر فرم قرار داردnextStep
/ previousStep
: توابعی برای پیمایش بین مراحلgoToStep
: تابعی برای پرش مستقیم به یک مرحله خاصsteps
: لیست کامل از آبجکتهای تایپ FormStep
با در دسترس قرار دادن این ویژگیها و متدها در context، فرم ما قابل پیکربندی و استفاده در هر کامپوننت child خواهد بود.
در این بخش، فرآیند ساخت کامپوننت 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
در 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 یا سرور جایگزین کرد.
توابع 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
مسئول جابهجایی مرحلهای فرم است. با این حال، میخواهیم قبل از تغییر مرحله، اعتبارسنجی فیلدهای همان مرحله انجام شود؛ بنابراین، این تابع را کمی اصلاح خواهیم کرد:
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' }
اکنون که دادهها به فرمت صحیح تبدیل شدهاند، آنها را در برابر currentStep.validationSchem
مرحله جاری بررسی میکنیم. اگر خطایی وجود داشته باشد، با استفاده از methods.setError
گزارش میشود.
در نهایت، اگر تمام اعتبارسنجیها با موفقیت انجام شوند، کاربر به مرحله بعد منتقل خواهد شد.
حالا که کامپوننت 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
در واقع دو نقش ایفا میکند:
type='button'
است و با عنوان Continue
عمل میکند.Submit
تغییر مییابد و با داشتن type="submit"
باعث ارسال فرم میشود.هر مرحله از فرم یک کامپوننت مستقل است که از الگوی مشخصی پیروی میکند:
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
مرورگر ذخیره شود.
اما ابتدا، ساختار دادههایی که میخواهیم ذخیره کنیم به چه صورت خواهد بود؟
type StoredFormState = { currentStepIndex: number formValues: Record<string, unknown> }
علاوهبر ذخیره state فیلدهای فرم، شماره مرحله جاری (ایندکس مرحله) را هم ذخیره میکنیم تا کاربر بتواند دقیقاً از همان جایی که متوقف شده ادامه دهد.
ابتدا، 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 فرم در localStorage
تعریف میکنیم:
// stepped-form.tsx const saveFormState = (stepIndex: number) => { setSavedFormState({ currentStepIndex: stepIndex ?? currentStepIndex, formValues: methods.getValues(), }); };
در React، بهروزرسانی state بهصورت asynchronous انجام میشود. یعنی وقتی کاربر به مرحله جدیدی میرود، مقدار currentStepIndex
بعد از انجام navigation بهروزرسانی میشود. اگر state فرم را با استفاده از مقدار قبلی currentStepIndex
ذخیره کنیم، مرحله اشتباهی ذخیره میشود.
برای مثال:
currentStepIndex = 0
)Next
را کلیک میکند تا به مرحله دوم برودcurrentStepIndex
همچنان 0
است تا زمانی که بروزرسانی 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 و مدرن ساختیم که:
معماری این کامپوننت بهگونهای است که بهراحتی میتوان مراحل جدید اضافه کرد یا مراحل موجود را تغییر داد، بدون اینکه منطق اصلی فرم تغییر کند.
دیدگاهها: