I have recently migrated my project from react-hook-form (rhf) over to @tanstack/form and for the most part have really enjoyed the implementation. One issue I have encountered and cannot find a solution for is a multi-step form. My current implementation is using a zustand store and making each form step its own form that saves the data to the store and pushes to the next step. If the user is on the last step, the form will submit all the data gathered. The issue I have is that each step is basically the exact same code except for the submit function and default values which isn't very DRY. Trying to create a reusable form step component doesn't seem possible with @tanstack/form. This is an example of one section:
// schema.ts
import { z } from "zod";
const communicationSettingsSchema = z.object({
emailCommunications: z.boolean(),
emailUpdates: z.boolean(),
});
export type CommunicationSettingsSchema = z.infer<typeof communicationSettingsSchema>;
const communicationSettingsSchemaDefaultValues: CommunicationSettingsSchema = {
emailCommunications: true,
emailUpdates: false,
};
export { communicationSettingsSchema, communicationSettingsSchemaDefaultValues };
// component.tsx
"use client";
import { EllipsisVerticalIcon } from "lucide-react";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import {
FormSection,
FormSectionContent,
FormSectionDescription,
FormSectionHeader,
FormSectionTitle,
} from "@/features/settings/components/form-section";
import { COMMUNICATION_SECTION as section } from "@/features/settings/constants";
import {
communicationSettingsSchemaDefaultValues as defaultValues,
communicationSettingsSchema as schema,
} from "@/features/settings/schemas";
import { useOnboardingStepsStore } from "@/features/settings/stores/use-onboarding-steps-store";
import { sleep } from "@/utils/sleep";
import { useAppForm } from "@/hooks/forms";
type Props = {
step: number;
};
function OnboardingFormCommunicationSection({ step }: Props) {
// store actions
const currentStep = useOnboardingStepsStore((state) => state.currentStep);
// store actions
const setPreviousStep = useOnboardingStepsStore((state) => state.setPreviousStep);
const setSettings = useOnboardingStepsStore((state) => state.setSettings);
const setActiveSteps = useOnboardingStepsStore((state) => state.setActiveSteps);
const setNextStep = useOnboardingStepsStore((state) => state.setNextStep);
// form methods
const form = useAppForm({
defaultValues,
validators: { onSubmit: schema },
onSubmit: async ({ value }) => {
// simulate async operation
await sleep({ ms: 150 });
// update store preferences
setSettings(value);
// mark the next step available
setActiveSteps(2);
setNextStep();
},
});
if (step === currentStep)
return (
<form
className="space-y-6"
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
<FormSection>
<FormSectionHeader>
<FormSectionTitle>{section.title}</FormSectionTitle>
<FormSectionDescription>{section.description}</FormSectionDescription>
</FormSectionHeader>
<FormSectionContent>
{section.settings.map((setting) => {
return (
<form.AppField
key={setting.id}
name={setting.schemaName as keyof z.infer<typeof schema>}
>
{(field) => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<div className="grid grid-cols-[auto_1fr_auto] gap-2">
<Switch
id={field.name}
name={field.name}
checked={field.state.value}
onBlur={field.handleBlur}
onCheckedChange={(v) => field.handleChange(Boolean(v))}
aria-invalid={isInvalid}
/>
<div>
<FieldLabel htmlFor={setting.fieldLabel}>
{setting.fieldLabel}
</FieldLabel>
<FieldDescription>{setting.fieldDescription}</FieldDescription>
</div>
<Popover>
<PopoverTrigger asChild>
<Button type="button" variant="ghost" size="icon">
<EllipsisVerticalIcon className="size-4" />
</Button>
</PopoverTrigger>
<PopoverContent align="end">
<p className="text-muted-foreground text-xs text-balance">
{setting.helperText}
</p>
</PopoverContent>
</Popover>
</div>
</Field>
);
}}
</form.AppField>
);
})}
</FormSectionContent>
</FormSection>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setPreviousStep()}
disabled={currentStep === 1}
>
Prev
</Button>
<form.AppForm>
<form.SubmitButton label="Next" />
</form.AppForm>
</div>
</form>
);
}
export { OnboardingFormCommunicationSection };
I'd like to create a component that allows me to pass the form like so:
const Example = () => {
// form
const form = useAppForm({
defaultValues,
validators: { onSubmit: schema },
onSubmit: async ({ value }) => {
console.log(value);
},
});
return <ReusableComponent form={form}/>
};
export { Example };