I’m building a reusable form component in React that supports both:
- Simple (single-step) forms, and
- Multi-step forms (each step has its own schema and inputs).
I’m using:
react-hook-form
- Zod for schema validation
- Custom wrapper components like
FormWrapper, RHFInput
Problem:
- Each step has its own Zod schema and inputs(there can be many inputs like select, multi select, switch, text area, etc. I am taking two inputs in each step) (e.g., name in step 1, email in step 2).
- Validation works fine for each step individually.
- On submitting, I only get the values of the last step.
- When I fill all inputs, go to the next step, then go back — it shows values from the next step instead of restoring the previous step’s inputs correctly.
- Going back to a previous step loses the earlier step’s field values.
- On final submission, I only get the values from the last step (e.g., just email) — values from earlier steps (e.g., name) are missing.
- I want to preserve values from all steps, and on submit, combine and submit all form values.
RHFInput.tsx
import {
forwardRef,
memo,
type Ref,
type RefCallback,
type RefObject,
} from "react";
import { useFormContext, type FieldValues, type Path } from "react-hook-form";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
type RHFInputProps<T extends FieldValues> = {
name: Path<T>;
label?: string;
placeholder?: string;
type?: string;
disabled?: boolean;
};
function mergeRefs<T>(...refs: (Ref<T> | undefined)[]): RefCallback<T> {
return (value: T) => {
refs.forEach((ref) => {
if (typeof ref === "function") {
ref(value);
} else if (ref && typeof ref === "object") {
(ref as RefObject<T>).current = value;
}
});
};
}
// 1. Define generic component WITHOUT forwardRef:
function RHFInputInner<T extends FieldValues>(
{ name, label, placeholder, type = "text", disabled }: RHFInputProps<T>,
ref: Ref<HTMLInputElement>
) {
const { control } = useFormContext<T>();
return (
<FormField
control={control}
name={name}
render={({ field }) => {
const { ref: fieldRef, ...restField } = field;
return (
<FormItem>
{label && <FormLabel>{label}</FormLabel>}
<FormControl>
<Input
type={type}
placeholder={placeholder}
disabled={disabled}
ref={mergeRefs(fieldRef, ref)}
{...restField}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
);
}
const RHFInput = forwardRef(RHFInputInner) as <T extends FieldValues>(
props: RHFInputProps<T> & { ref?: Ref<HTMLInputElement> }
) => React.ReactElement;
export default memo(RHFInput);
FormWrapper.tsx
import type { ReactNode } from "react";
import type {
DefaultValues,
FieldValues,
SubmitHandler,
UseFormProps,
UseFormReturn,
} from "react-hook-form";
import { ZodSchema, ZodType } from "zod";
export type FormContext<T extends Record<string, unknown>> =
UseFormReturn<T> & {
readOnly: boolean;
};
export type FormStep<T extends FieldValues> = {
schema: ZodType<T>;
content: React.ReactNode;
};
export interface FormWrapperProps<T extends FieldValues> {
isMultiStep?: boolean;
mode?: UseFormProps<T>["mode"];
readOnly?: boolean;
defaultValues?: DefaultValues<T>;
children?: ReactNode;
onSubmit: SubmitHandler<T>;
steps?: FormStep<T>[];
submitLabel?: string;
schema: ZodSchema<T>;
className?: string;
}
import { memo, useCallback, useState } from "react";
import { isEqual } from "lodash";
import { FormProvider, useForm, type FieldValues } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { cn } from "@/lib/utils";
import { Form } from "@/components/ui/form";
import { Button } from "@/components/ui/button";
const FormWrapper = <T extends FieldValues>({
isMultiStep,
mode = "all",
readOnly = false,
defaultValues,
onSubmit,
steps,
submitLabel = "Submit",
schema,
children,
className,
}: FormWrapperProps<T>) => {
const [step, setStep] = useState(0);
const currentStep = isMultiStep ? steps?.[step] : undefined;
const currentSchema = isMultiStep ? steps?.[step]?.schema ?? schema : schema;
const methods = useForm({
mode,
defaultValues,
resolver: zodResolver(currentSchema),
});
const extendedForm: FormContext<T> = {
...methods,
readOnly,
};
const handleNext = useCallback(async () => {
const valid = await methods.trigger();
if (!valid) return;
setStep((prev) => Math.min(prev + 1, (steps?.length ?? 1) - 1));
}, [methods, steps]);
const handlePrev = useCallback(() => {
setStep((prev) => Math.max(prev - 1, 0));
}, []);
const handleFinalSubmit = useCallback(
(data: T) => {
onSubmit(data);
},
[onSubmit]
);
const isFirstStep = step === 0;
const isLastStep = step === (steps?.length ?? 1) - 1;
const canGoNext = !isLastStep;
const canGoPrev = !isFirstStep;
return (
<FormProvider {...extendedForm}>
<Form {...extendedForm}>
<form
onSubmit={methods.handleSubmit(handleFinalSubmit)}
className={cn("space-y-4", className)}
>
{isMultiStep ? currentStep?.content : children}
{isMultiStep ? (
<div
className={cn(
"flex items-center w-full",
canGoPrev ? "justify-between" : "justify-end"
)}
>
<div>
{canGoPrev && (
<Button
type="button"
variant="destructive"
onClick={handlePrev}
>
Prev
</Button>
)}
</div>
<div className="flex items-center gap-4">
{canGoNext ? (
<Button type="button" onClick={handleNext}>
Next
</Button>
) : (
<Button type="submit">{submitLabel}</Button>
)}
</div>
</div>
) : (
<Button type="submit" className="float-right">
{submitLabel}
</Button>
)}
</form>
</Form>
</FormProvider>
);
};
export default memo(FormWrapper, isEqual);
UserPage.tsx
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import FormWrapper from "@/components/form/form-wrapper";
import RHFInput from "@/components/form/controller/RHFInput";
import { z } from "zod";
import type { FormStep } from "@/components/form/types";
const step1Schema = z.object({
name: z.string().min(1, "Name is required"),
});
const step2Schema = z.object({
email: z.string().email("Invalid email"),
});
const mergedSchema = step1Schema.merge(step2Schema);
type FormType = z.infer<typeof mergedSchema>
const steps: FormStep<FormType>[] = [
{
schema: step1Schema,
content: (
<RHFInput name="name" label="Name" placeholder="Enter your name" />
),
},
{
schema: step2Schema,
content: (
<RHFInput name="email" label="Email" placeholder="Enter your email" />
),
},
];
const UserPage = () => {
return (
<Dialog>
<DialogTrigger asChild>
<Button type="button">Create Plan</Button>
</DialogTrigger>
<DialogContent className="no-scrollbar">
<DialogHeader>
<DialogTitle className="text-start">Create New Plan</DialogTitle>
</DialogHeader>
<FormWrapper
schema={mergedSchema}
defaultValues={{ name: "", email :""}}
onSubmit={(data) => console.log("Submitted data", data)}
isMultiStep
steps={steps}
>
</FormWrapper>
</DialogContent>
</Dialog>
);
};
export default UserPage;