DEV Community

Cover image for Engineering Scalable Forms: Decoupling Logic with RHF and Next.js App Router
Devansh Prajapati
Devansh Prajapati

Posted on

Engineering Scalable Forms: Decoupling Logic with RHF and Next.js App Router

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>
Enter fullscreen mode Exit fullscreen mode

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
};

Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

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>();
}

Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

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",
    });
}

Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

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 })}
/>

Enter fullscreen mode Exit fullscreen mode

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:

  1. Maintainability: Our parent component remains lean, acting only as an orchestrator while individual "domains" (Personal Info, Experience) manage their own UI logic.

  2. 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)