| name | multi-step-form |
| description | Guide for building multi-step forms with sequential wizard creation and tab-based editing. Use when creating complex forms that span multiple sections, require per-step validation, or involve related entities (e.g., parent + children records). Based on the SME create/edit pattern. |
Multi-Step Form Pattern
This skill defines how to build multi-step forms in this codebase. Follow these patterns when creating forms that collect data across multiple sections or involve related entities.
Architecture Overview
Multi-step forms use two distinct strategies:
| Mode | Pattern | Navigation | Save Behavior |
|---|
| Create | Sequential wizard with Progress bar | Next/Previous buttons, per-step validation gates | Single final submission creates all entities |
| Edit | Tab-based interface with Tabs | Click any tab freely | Each tab saves independently |
Key Principles
- Create = wizard, Edit = tabs - Creation walks users through steps in order. Editing lets users jump to any section.
- Per-step validation before advancing - Never let the user proceed to the next step with invalid data.
- Single error state - Use one
errors: Record<string, string> for the entire form, cleared on step change or successful validation.
- forwardRef + useImperativeHandle - Expose
handleSubmit to the parent CrudPage component. On create forms, this either advances to next step or submits on the final step.
- Sequential API calls on create - Create the parent entity first, then child entities using the parent's returned ID.
- Independent saves on edit - Each tab has its own save handler. Only the active tab saves when the user clicks Save.
- Double submission guard - Use an
isSubmitting flag to prevent duplicate submissions.
- Snake/camel case conversion - Frontend uses camelCase, backend uses snake_case. Convert with
snakefiyKeys() on submit, manual mapping on load.
File Structure
For an entity called {Entity}:
resources/js/pages/{Entity}/sections/
{Entity}CreateForm.tsx # Sequential wizard (forwardRef)
{Entity}EditForm.tsx # Wrapper that fetches related data
{Entity}EditFormSimple.tsx # Tab-based form (forwardRef)
edit-tabs/
{Entity}Edit{Section}Tab.tsx # One component per tab
Form State Management
Each logical section gets its own useState:
const [businessData, setBusinessData] = useState<EntityCreateData>({...defaults});
const [ownerData, setOwnerData] = useState<OwnerData>({...defaults});
const [detailsData, setDetailsData] = useState<DetailsData>({...defaults});
const [errors, setErrors] = useState<Record<string, string>>({});
const [currentStep, setCurrentStep] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
Update with spread: setBusinessData({ ...businessData, fieldName: value }).
For array fields (multi-select), use add/remove helpers:
const addItem = (value: string) => {
if (value && !data.items.includes(value)) {
setData({ ...data, items: [...data.items, value] });
}
};
const removeItem = (value: string) => {
setData({ ...data, items: data.items.filter(v => v !== value) });
};
Step Definitions
Define steps as a constant array:
const STEPS = [
{ id: 1, name: 'Business Information', description: 'Basic details' },
{ id: 2, name: 'Primary Owner', description: 'Owner information' },
{ id: 3, name: 'Formalization', description: 'Status details' },
{ id: 4, name: 'Additional Members', description: 'Optional team members' },
{ id: 5, name: 'Review', description: 'Review and submit' },
];
Validation Pattern
Validate per-step before allowing navigation:
const handleNext = () => {
let isValid = true;
if (currentStep === 1) isValid = validateStep1();
else if (currentStep === 2) isValid = validateStep2();
if (isValid && currentStep < STEPS.length) {
setCurrentStep(currentStep + 1);
setErrors({});
}
};
Each validation function builds an errors object and returns a boolean:
const validateStep1 = (): boolean => {
const newErrors: Record<string, string> = {};
if (!data.name?.trim()) newErrors.name = 'Name is required';
if (data.phone && !validateMalawiPhone(data.phone)) {
newErrors.phone = VALIDATION_MESSAGES.PHONE;
}
if (!data.items || data.items.length === 0) {
newErrors.items = 'At least one item is required';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
Use validators from @/lib/malawi-validators for phone, national ID, TIN, and business registration fields.
useImperativeHandle Pattern
Expose form submission to parent CrudPage:
useImperativeHandle(ref, () => ({
handleSubmit: () => {
if (currentStep === STEPS.length) {
handleSubmit();
} else {
handleNext();
}
}
}));
useImperativeHandle(ref, () => ({
handleSubmit: () => {
switch (activeTab) {
case 'section-a': return handleSaveSectionA();
case 'section-b': return handleSaveSectionB();
}
}
}));
Create Form Submission
Submit all entities sequentially, using the parent ID for children:
const handleSubmit = async () => {
if (isSubmitting) return;
setIsSubmitting(true);
try {
const parentRes = await fetch('/api/entities', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: JSON.stringify(snakefiyKeys(parentData)),
});
const parentJson = await parentRes.json();
const parentId = parentJson.data.id;
await fetch('/api/child-entities', {
method: 'POST',
body: JSON.stringify({ ...snakefiyKeys(childData), parent_id: parentId }),
headers: { },
});
await fetch('/api/auto-entities/upsert', {
method: 'POST',
body: JSON.stringify({ ...snakefiyKeys(autoData), parent_id: parentId }),
headers: { },
});
for (const member of members) {
await fetch('/api/members', {
method: 'POST',
body: JSON.stringify({ ...snakefiyKeys(member), parent_id: parentId }),
headers: { },
});
}
onSuccess('Entity created successfully');
onEntityCreated?.({ id: parentId, ...parentJson.data });
} catch (error) {
onError?.(error);
} finally {
setIsSubmitting(false);
}
};
Edit Form: Data Loading Wrapper
The edit form uses a wrapper component to fetch related data in parallel:
export const EntityEditForm = forwardRef<any, Props>(({ item, ...props }, ref) => {
const [relatedA, setRelatedA] = useState<TypeA | undefined>();
const [relatedB, setRelatedB] = useState<TypeB | undefined>();
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchRelated = async () => {
setLoading(true);
const [aRes, bRes] = await Promise.all([
axios.get(`/api/entities/${item.id}/related_a`).catch(() => ({ data: { data: null } })),
axios.get(`/api/entities/${item.id}/related_b`).catch(() => ({ data: { data: [] } })),
]);
setRelatedA(convertSnakeToCamel(aRes.data.data));
setRelatedB(convertSnakeToCamel(bRes.data.data));
setLoading(false);
};
if (item?.id) fetchRelated();
}, [item?.id]);
if (loading) return <Loader2 className="h-8 w-8 animate-spin" />;
return <EntityEditFormSimple ref={ref} item={item} relatedA={relatedA} relatedB={relatedB} {...props} />;
});
Edit Form: Tab-Based Save
Each tab saves independently with its own validation and API call:
const handleSaveSection = async () => {
const newErrors: Record<string, string> = {};
if (Object.keys(newErrors).length > 0) { setErrors(newErrors); return; }
setErrors({});
setIsSaving?.(true);
try {
const response = await fetch(`/api/sections/${section.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: JSON.stringify(snakefiyKeys(sectionData)),
});
if (response.ok) onSuccess('Section updated');
else onError?.(await response.json().catch(() => ({})));
} catch (error) { onError?.(error); }
finally { setIsSaving?.(false); }
};
Edit Form: Additional Members (Array Children)
Track original IDs to detect deletions:
const [members, setMembers] = useState<MemberData[]>(initialMembers);
const [originalMemberIds] = useState<number[]>(initialMembers.filter(m => m.id).map(m => m.id!));
const handleSaveMembers = async () => {
const currentIds = members.filter(m => m.id).map(m => m.id!);
const deletedIds = originalMemberIds.filter(id => !currentIds.includes(id));
for (const id of deletedIds) {
await fetch(`/api/members/${id}`, { method: 'DELETE', headers: {...} });
}
for (const member of members) {
if (member.id) {
await fetch(`/api/members/${member.id}`, { method: 'PUT', body: JSON.stringify(snakefiyKeys({...member, parentId})), headers: {...} });
} else {
const res = await fetch('/api/members', { method: 'POST', body: JSON.stringify(snakefiyKeys({...member, parentId})), headers: {...} });
if (res.ok) { const data = await res.json(); member.id = data.data?.id; }
}
}
setMembers([...members]);
onSuccess('Members saved');
};
UI Components Used
| Purpose | Component | Import |
|---|
| Text input | Input | @/components/ui/input |
| Select dropdown | Select, SelectContent, SelectItem, SelectTrigger, SelectValue | @/components/ui/select |
| Multi-line text | Textarea | @/components/ui/textarea |
| Boolean toggle | Switch | @/components/ui/switch |
| Checkbox | Checkbox | @/components/ui/checkbox |
| Multi-select | Popover + Checkbox + Badge | @/components/ui/popover, @/components/ui/badge |
| Progress bar | Progress | @/components/ui/progress |
| Section cards | Card, CardContent, CardHeader, CardTitle | @/components/ui/card |
| Tab navigation | Tabs, TabsContent, TabsList, TabsTrigger | @/components/ui/tabs |
| Labels | Label | @/components/ui/label |
Data Conversion
Frontend camelCase to backend snake_case: use snakefiyKeys() from @/lib/utils.
Backend snake_case to frontend camelCase: manually map in useEffect when loading:
setPrimaryOwner({
firstName: data.first_name || '',
dateOfBirth: data.date_of_birth ? data.date_of_birth.slice(0, 10) : undefined,
hasSpecialNeeds: data.has_special_needs || false,
});
Date fields from backend include timestamps (2024-01-15T00:00:00Z) - slice to YYYY-MM-DD for date inputs.
Config-Driven Options
Fetch dynamic select options from the configs API:
useEffect(() => {
const fetchConfigs = async () => {
const [catRes, secRes] = await Promise.all([
axios.get('/api/configs?config_type=business_category'),
axios.get('/api/configs?config_type=sector'),
]);
setCategoryOptions(catRes.data.data.map((c: any) => c.value));
setSectorOptions(secRes.data.data.map((s: any) => s.value));
};
fetchConfigs();
}, []);
Checklist
When building a new multi-step form, ensure:
For detailed code examples, see examples/sme-create-form.md and examples/sme-edit-form.md.