Introduction:
Implementing a simple and single page using react-hook-form is very easy and we do it quite often in our applications. But it fails when it comes to modern web architecture and business logic. Large single-page forms aren't just "unattractive"; they are difficult to validate, hard to debug, and overwhelming for the user.
Transitioning to a Multi-Step Wizard in Next.js introduces a unique architectural challenge: State Persistence across Route Transitions. We will implement a scalable pattern that manages state transition and validation across the Next.js App Router using FormProvider, useFromContext and useFieldArray.
Phase 1: Implementing Contextual Injection and Static Domains
RHF utilizes the FormProvider pattern to wrap component trees in a shared context. This decouples nested UI elements from the parent form's implementation details, allowing deep child components to consume form methods via useFormContext without polluting the intermediate component props.
To implement FormProvider, initialize the form using useForm and spread the resulting methods onto the provider. This establishes a shared context that allows nested components to access form state viauseFormContext without manual prop drilling.
const methods = useForm<exampleSchema>({
resolver: zodResolver(exampleSchema),
defaultValues: {}
});
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)} >
{/* Rest of the Code... */}
</form>
</FormProvider>
Take a look at the “Dynamic Job Application Portal” Example:
/components/job-form
├── index.tsx // The "Parent" (FormProvider lives here)
├── PersonalInfo.tsx // Child 1 (Simple inputs)
├── ExperienceSection.tsx // Child 2 (useFieldArray lives here)
├── SkillPicker.tsx // Child 3 (Controller & useWatch live here)
└── SubmitButton.tsx // Child 4 (Validation check)
Type Definitions:
export type JobFormValues = {
personalInfo: {
firstName: string;
lastName: string;
email: string;
};
experience: {
company: string;
role: string;
years: number;
}[];
skills: string[];
jobCategory: "developer" | "designer" | ""; // Used for conditional logic
};
Initializing React-Hook-Form
// index.tsx
//crucial for use hooks
"use client";
import { useForm, FormProvider } from "react-hook-form";
// Optional: If using Zod for validation
import { zodResolver } from "@hookform/resolvers/zod";
// Default Values for the form, ensuring all fields are initialized properly
const defaultValues: JobFormValues = {
personalInfo: {
firstName: "",
lastName: "",
email: "",
},
experience: [
{ company: "", role: "", years: 0 } // Starts with one empty row
],
skills: [],
jobCategory: "",
};
export default function JobApplicationForm() {
const methods = useForm<JobFormValues>({
// resolver: zodResolver // You can define resolver for type safety
defaultValues,
mode: "onBlur",
});
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit((data) => console.log(data))}>
{/* Child components here */}
</form>
</FormProvider>
);
}
useFormContext
RHF provides the useFormContext method that allows nested components to access the form's internal state and methods without receiving them through props . This decouples nested UI elements from the parent form's implementation details, allowing deep child components to consume form methods via useFormContext without polluting the intermediate component props.
"use client";
import { useFormContext } from "react-hook-form";
export default function PersonalInfo() {
// We can now access the form context, including register and errors, without prop drilling
// de-structure register and formState
const { register, formState: { errors } } = useFormContext<exampleSchema>();
}
Initializing useFormContext
// PersonalInfo.tsx
"use client";
import { useFormContext } from "react-hook-form";
import { JobFormValues } from "./index"; // Import the type for safety
export default function PersonalInfo() {
// You got this type of return form useFormContext
{/*
{
control: { ... }, // The internal engine
register: ƒ (), // Function to hook up inputs
handleSubmit: ƒ (), // Function to wrap submission
watch: ƒ (), // Function to observe changes
formState: { // The "Health" of the form
errors: {
personalInfo: { firstName: { message: "Required" } }
},
isDirty: true,
isValid: false,
isSubmitting: false
},
setValue: ƒ (), // Update data programmatically
getValues: ƒ (), // Read data silently
reset: ƒ (), // Go back to start
…
}
*/}
// 1. Pluck only what we need from the useFormContext
const { register, formState: { errors } } = useFormContext<JobFormValues>();
return (
<section className="space-y-4 p-4 border rounded-lg bg-gray-50">
<h2 className="text-xl font-bold">Personal Information</h2>
<div className="grid grid-cols-2 gap-4">
{/* 2. Using Nested Paths: "parent.child" */}
<div className="flex flex-col">
<label>First Name</label>
<input
{...register("personalInfo.firstName")}
placeholder="Jane"
className="border p-2 rounded"
/>
{errors.personalInfo?.firstName && (
<span className="text-red-500 text-sm">{errors.personalInfo.firstName.message}</span>
)}
</div>
<div className="flex flex-col">
<label>Email Address</label>
<input
{...register("personalInfo.email")}
type="email"
placeholder="jane@example.com"
className="border p-2 rounded"
/>
{errors.personalInfo?.email && (
<span className="text-red-500 text-sm">{errors.personalInfo.email.message}</span>
)}
</div>
</div>
</section>
);
}
Beyond avoiding prop drilling, useFormContext grants components functional autonomy. By using this hook, a component like PersonalInfo becomes a standalone unit. You can move it across different steps of a wizard or into different layouts without modifying the component’s internal logic, as long as it remains within a FormProvider.
Note: Crucially, because FormProvider and useFormContext rely on React Context, these files must include the "use client" directive. This marks the boundary between your static Server Components and your interactive form logic.
Phase 2: Orchestrating Dynamic Collections with useFieldArray
While simple inputs are easy to manage, real-world forms often require users to add, remove, or reorder lists of data—such as work history or educational background. In React Hook Form, we handle this through the useFieldArray hook.
This hook provides a specialized set of methods (append, remove, move, insert) designed to manipulate arrays within your form state without losing focus or triggering unnecessary global re-renders.
Initialize useFieldArray:
"use client";
import { useFormContext, useFieldArray } from "react-hook-form";
export default function ExperienceSection() {
const { control } = useForm<exampleSchema>({
resolver: zodResolver(exampleSchema),
defaultValues: {}
});
const { fields, append, remove } = useFieldArray({
control,
name: "experience",
});
}
In React Hook Form, most inputs are uncontrolled (they use refs (When we do …register() we destructure the ref of that particular input field)). However, complex components like useFieldArray or Controller need a way to talk to the internal state of the library to stay in sync.
The control object contains the internal logic, refs, and state subscribers for the entire form.
Standard HTML inputs can use the register method. But dynamic arrays and custom UI components (like a Material UI slider) don't have a standard "ref" that RHF can easily grab.
- The control acts as the bridge. It provides the internal methods necessary to register these complex fields into the main form state.
// ExperienceSection.tsx
"use client";
import { useFormContext, useFieldArray} from "react-hook-form";
import { JobFormValues } from "./index";
export default function ExperienceSection() {
//useFormContext also contains the control that we can use to register our array fields to the form
const { control, register, formState: { errors } } = useFormContext<JobFormValues>();
// Connecting to the "experience" array in our defaultValues
const { fields, append, remove } = useFieldArray({
control,
name: "experience",
});
return (
<div className="space-y-6 p-4 border rounded-lg">
<div className="flex justify-between items-center">
<h2 className="text-xl font-bold">Work Experience</h2>
<button
type="button"
// Add blank field of array element to the array
onClick={() => append({ company: "", role: "", years: 0 })}
className="bg-green-600 text-white px-3 py-1 rounded text-sm"
>
+ Add Job
</button>
</div>
{fields.map((field, index) => (
<div key={field.id} className="grid grid-cols-3 gap-4 p-4 border-b relative">
<input
{...register(`experience.${index}.company` as const)}
placeholder="Company"
className="border p-2 rounded"
/>
<input
{...register(`experience.${index}.role` as const)}
placeholder="Role"
/>
<input
{...register(`experience.${index}.years` as const, { valueAsNumber: true })}
type="number"
placeholder="Years"
/>
<button
type="button"
// Remove the particular element from the array
onClick={() => remove(index)}
className="text-red-500 absolute right-0 top-0"
>
✕
</button>
</div>
))}
</div>
);
}
If you have 10 items in your "Experience" list and you click "Delete" on one, RHF needs to know exactly which index to remove and how to shift the remaining data.
The control object manages this calculation behind the scenes.
It ensures that only the ExperienceSection re-renders, while the rest of your form (like PersonalInfo) stays perfectly still. This is how RHF maintains high performance in large forms.
The Mechanics of Dynamic Mapping: experience.${index}.years
In a standard input, you might register a field simply as "firstName". However, when dealing with dynamic arrays, we use Object Path Notation. This string is the precise "GPS coordinate" for a piece of data within your form's state tree.
Breaking Down the Path
Think of your form data as a JSON object. The string experience.${index}.years tells RHF exactly where to perform the update:
experience: Identifies the top-level array defined in your defaultValues.
.${index}: The dynamic pointer. Whether the index is 0, 1, or 2, it tells RHF which specific object in the list is being modified.
.years: The specific key (property) within that nested object.
The Result: When a user types "5" into the second row of your form, RHF internally maps that value to: state.experience[1].years = 5.
The valueAsNumber Transformation
Since the years field is defined as a number in our TypeScript schema, always remember that HTML inputs return strings by default. To keep your data clean, use the valueAsNumber option:
<input
type="number"
{...register(`experience.${index}.years`, { valueAsNumber: true })}
/>
This ensures that your onSubmit receives the integer 5 instead of the string "5", keeping your API payloads type-safe.
Conclusion:
By shifting from a monolithic form structure to a decoupled architecture using FormProvider and useFieldArray, we’ve achieved three critical engineering goals:
Maintainability: Our parent component remains lean, acting only as an orchestrator while individual "domains" (Personal Info, Experience) manage their own UI logic.
Performance: By leveraging the control object, we ensure that updates to a single item in a dynamic list don't trigger expensive re-renders across the entire application.
3.
Scalability: This pattern is "future-proof." Whether you need to add five more sections or transition to a multi-step wizard, the underlying logic of using useFormContext and nested paths remains the same.
Top comments (0)