Skip to main content

Multi-Step Forms

The Ever Works Template includes a generic multi-step form system with step tracking, progress calculation, validation management, and a concrete implementation for item creation/editing. The system splits complex forms into manageable steps with navigation controls, a visual step indicator, and conditional step rendering.

Architecture Overview

ComponentPathPurpose
useMultiStepFormhooks/use-multi-step-form.tsGeneric multi-step form state management hook
MultiStepItemFormcomponents/admin/items/multi-step-item-form.tsxItem form implementation using the multi-step hook
StepIndicatorcomponents/ui/multi-step-form.tsxVisual step progress indicator
StepNavigationcomponents/ui/multi-step-form.tsxPrevious/Next/Submit navigation buttons
Form Stepscomponents/admin/items/form-steps/Individual step components (BasicInfo, MediaLinks, etc.)

The useMultiStepForm Hook

A generic, reusable hook for managing multi-step form state:

Interface

interface UseMultiStepFormOptions {
totalSteps: number; // Total number of steps
initialStep?: number; // Starting step (default: 1)
onStepChange?: (step: number) => void; // Callback on step change
onComplete?: () => void; // Callback when form is completed
}

interface UseMultiStepFormReturn {
currentStep: number;
isFirstStep: boolean;
isLastStep: boolean;
completedSteps: Set<number>;
progress: number; // Percentage (0-100)
goToNext: () => boolean;
goToPrevious: () => boolean;
goToStep: (step: number) => boolean;
markStepAsCompleted: (step: number) => void;
markStepAsIncomplete: (step: number) => void;
reset: () => void;
}

Usage

import { useMultiStepForm } from '@/hooks/use-multi-step-form';

function MyWizard() {
const {
currentStep,
isFirstStep,
isLastStep,
completedSteps,
progress,
goToNext,
goToPrevious,
goToStep,
markStepAsCompleted
} = useMultiStepForm({
totalSteps: 4,
initialStep: 1,
onStepChange: (step) => console.log('Now on step:', step),
onComplete: () => console.log('Form completed!')
});

return (
<div>
<p>Step {currentStep} of 4 ({progress}% complete)</p>
<button onClick={goToPrevious} disabled={isFirstStep}>Back</button>
<button onClick={goToNext} disabled={isLastStep}>Next</button>
</div>
);
}
MethodReturnsDescription
goToNext()booleanAdvances to next step; calls onComplete if on last step; returns false if cannot advance
goToPrevious()booleanReturns to previous step; returns false if on first step
goToStep(step)booleanJumps to a specific step; returns false if step is out of bounds
markStepAsCompleted(step)voidAdds step to the completed set
markStepAsIncomplete(step)voidRemoves step from the completed set
reset()voidResets to initial step and clears all completed steps

Progress Calculation

Progress is calculated as a percentage based on the current step:

const progress = (currentStep / totalSteps) * 100;

Boundary Guards

The hook includes guards to prevent invalid navigation:

const goToStep = (step: number): boolean => {
if (step < 1 || step > totalSteps) {
return false; // Out of bounds
}
setCurrentStep(step);
onStepChange?.(step);
return true;
};

const goToNext = (): boolean => {
if (isLastStep) {
onComplete?.();
return false; // Already at last step
}
return goToStep(currentStep + 1);
};

Multi-Step Item Form

The MultiStepItemForm is a concrete implementation that uses useMultiStepForm for creating and editing items:

import { MultiStepItemForm } from '@/components/admin/items/multi-step-item-form';

<MultiStepItemForm
item={existingItem} // null for create mode
mode="create" // 'create' | 'edit'
onSubmit={(data) => handleSubmit(data)}
onCancel={() => router.back()}
isLoading={false}
/>

Form Steps

The form consists of up to 5 steps, with the Location step being conditional:

StepComponentData TypeDescription
1BasicInfoStepBasicInfoDataItem name, slug, and description
2MediaLinksStepMediaLinksDataIcon URL and source URL
3ClassificationStepClassificationDataCategory and tags arrays
4 (conditional)LocationStepLocationStepDataAddress, coordinates, service area
4 or 5ReviewStepReviewDataFeatured status and item status

Conditional Location Step

The Location step is conditionally included based on location settings:

const { settings: locationSettings } = useLocationSettings();
const locationEnabled = locationSettings.enabled;

const FORM_STEPS = useMemo(() => {
const steps = [
{ id: 'basic-info', title: t('STEPS.BASIC_INFO.TITLE'), description: '...' },
{ id: 'media-links', title: t('STEPS.MEDIA_LINKS.TITLE'), description: '...' },
{ id: 'classification', title: t('STEPS.CLASSIFICATION.TITLE'), description: '...' },
];

if (locationEnabled) {
steps.push({ id: 'location', title: t('STEPS.LOCATION.TITLE'), description: '...' });
}

steps.push({ id: 'review', title: t('STEPS.REVIEW.TITLE'), description: '...' });

return steps;
}, [t, locationEnabled]);

Form Data Structure

interface FormData {
basicInfo: {
id: string;
name: string;
slug: string;
description: string;
};
mediaLinks: {
icon_url: string;
source_url: string;
};
classification: {
category: string[];
tags: string[];
};
location: {
address?: string;
city?: string;
state?: string;
country?: string;
postal_code?: string;
latitude?: number;
longitude?: number;
service_area?: string;
is_remote?: boolean;
geocoded_by?: string;
};
review: {
featured: boolean;
status: string;
};
}

Step Validation

Each step component reports its validation state through a callback:

const handleStepValidation = (step: number, isValid: boolean) => {
setStepValidation(prev => ({ ...prev, [step]: isValid }));

if (isValid) {
markStepAsCompleted(step);
} else {
markStepAsIncomplete(step);
}
};

Navigation to the next step is only allowed when the current step is valid:

const handleNext = () => {
if (stepValidation[currentStep]) {
goToNext();
}
};

Step Click Navigation

Users can click on completed steps to navigate back:

const handleStepClick = (step: number) => {
const canNavigate = completedSteps.has(step);
if (canNavigate) {
goToStep(step);
}
};

Form Submission

On the final step, all form data sections are combined into a single request object:

function handleFormSubmit() {
const combinedData = {
...formData.basicInfo,
...formData.mediaLinks,
...formData.classification,
...formData.review,
...(locationEnabled && hasLocationData(formData.location)
? { location: formData.location }
: {}),
};
onSubmit(combinedData);
}

Edit Mode Data Population

When editing an existing item, form data is populated from the item prop:

useEffect(() => {
if (item && mode === 'edit') {
setFormData({
basicInfo: { id: item.id, name: item.name, slug: item.slug, description: item.description },
mediaLinks: { icon_url: item.icon_url || '', source_url: item.source_url },
classification: {
category: Array.isArray(item.category) ? item.category : [],
tags: Array.isArray(item.tags) ? item.tags : []
},
location: { /* ...mapped from item.location */ },
review: { featured: item.featured || false, status: item.status }
});
}
}, [item, mode]);

UI Components

Step Indicator

Displays a visual progress bar with step circles:

<StepIndicator
steps={FORM_STEPS}
currentStep={currentStep}
completedSteps={completedSteps}
onStepClick={handleStepClick}
className="mb-8"
/>

Step Navigation

Renders Previous, Next, Submit, and Cancel buttons:

<StepNavigation
currentStep={currentStep}
totalSteps={FORM_STEPS.length}
isFirstStep={isFirstStep}
isLastStep={isLastStep}
canGoNext={canGoNext}
canGoPrevious={canGoPrevious}
isSubmitting={isLoading}
onNext={handleNext}
onPrevious={handlePrevious}
onCancel={onCancel}
nextLabel={t('NAVIGATION.NEXT')}
previousLabel={t('NAVIGATION.PREVIOUS')}
submitLabel={mode === 'create' ? t('NAVIGATION.CREATE') : t('NAVIGATION.UPDATE')}
cancelLabel={t('NAVIGATION.CANCEL')}
stepCounterLabel={t('NAVIGATION.STEP_COUNTER', { current: currentStep, total: FORM_STEPS.length })}
/>

Internationalization

All form labels and step descriptions use next-intl under the admin.ITEM_FORM namespace:

KeyUsage
STEPS.BASIC_INFO.TITLEStep 1 title
STEPS.MEDIA_LINKS.TITLEStep 2 title
STEPS.CLASSIFICATION.TITLEStep 3 title
STEPS.LOCATION.TITLEStep 4 title (conditional)
STEPS.REVIEW.TITLEFinal step title
NAVIGATION.NEXTNext button label
NAVIGATION.PREVIOUSPrevious button label
NAVIGATION.CREATESubmit button label (create mode)
NAVIGATION.UPDATESubmit button label (edit mode)

Key Files

FilePath
Multi-Step Form Hookhooks/use-multi-step-form.ts
Multi-Step Item Formcomponents/admin/items/multi-step-item-form.tsx
Step UI Componentscomponents/ui/multi-step-form.tsx
Form Step Componentscomponents/admin/items/form-steps/