DEV Community

Karthik Reddy
Karthik Reddy

Posted on

The Evolution of Web Forms — Final Part

The Evolution of Web Forms — Final Part: Enterprise Architecture and the Future of Forms

In the previous parts, we followed the evolution of forms through several generations:

Plain HTML forms
        ↓
Native HTML validation
        ↓
Vanilla JavaScript validation
        ↓
AJAX submission
        ↓
Controlled React forms
        ↓
Form-management libraries
        ↓
React Hook Form
        ↓
Zod schemas
        ↓
Backend validation
        ↓
Accessible and performant forms
Enter fullscreen mode Exit fullscreen mode

At this point, we know how to build a reliable registration form.

However, many production forms are not simple registration forms.

Real applications contain:

  • Multi-step onboarding
  • Dynamic work-experience sections
  • Conditional fields
  • Nested objects
  • File uploads
  • Draft saving
  • Autosave
  • Asynchronous validation
  • Optimistic updates
  • Server-side actions
  • AI-assisted input
  • OCR document processing
  • Voice-based data entry

The final part of this series covers:

  1. Enterprise forms
  2. Modern production architecture
  3. The future of web forms

Stage 17: Enterprise Forms

An enterprise form is not defined only by its number of fields.

A form becomes complex when it must coordinate:

Many values
Multiple workflow steps
Conditional rules
Dynamic sections
Server communication
File processing
Draft recovery
Permission rules
Long-running operations
Multiple error sources
Enter fullscreen mode Exit fullscreen mode

Consider a job application form.

Step 1 — Personal information
├── Name
├── Email
├── Phone
└── Location

Step 2 — Professional information
├── Current role
├── Total experience
└── Skills

Step 3 — Work experience
├── Experience 1
├── Experience 2
└── Add another experience

Step 4 — Documents
├── Resume
├── Cover letter
└── Portfolio

Step 5 — Review and submit
Enter fullscreen mode Exit fullscreen mode

This is no longer just an object containing input values.

It is a workflow.


17.1 Multi-Step and Wizard Forms

A multi-step form divides one large form into smaller sections.

Large form
   ↓
Step 1
   ↓
Step 2
   ↓
Step 3
   ↓
Review
   ↓
Submit
Enter fullscreen mode Exit fullscreen mode

This can reduce the amount of information shown at one time.

It can also improve completion when steps follow a meaningful sequence.


The problem

Imagine presenting 50 fields on one page.

The user may experience:

  • Cognitive overload
  • Difficulty locating errors
  • Long scrolling
  • Fear of losing progress
  • Uncertainty about how much work remains
  • Poor mobile usability

A wizard reduces visible complexity.

However, it introduces new questions:

  • When should each step be validated?
  • Should hidden steps remain registered?
  • Where should values be stored?
  • Can users navigate backward?
  • Should progress be saved?
  • What happens after a refresh?
  • Should users skip optional steps?

Multi-step state model

A wizard needs both form state and workflow state.

interface WizardState {
  currentStep: number;
  completedSteps: number[];
  draftId?: string;
}
Enter fullscreen mode Exit fullscreen mode

The form library manages:

Values
Errors
Dirty fields
Touched fields
Enter fullscreen mode Exit fullscreen mode

The wizard manages:

Current step
Completed steps
Navigation
Progress
Draft status
Enter fullscreen mode Exit fullscreen mode

Do not mix these responsibilities unnecessarily.


Complete multi-step React Hook Form example

Install the required packages:

npm install react-hook-form zod @hookform/resolvers
Enter fullscreen mode Exit fullscreen mode

Create application.schema.ts:

import { z } from "zod";

export const applicationSchema = z.object({
  personal: z.object({
    firstName: z
      .string()
      .trim()
      .min(1, "First name is required."),

    lastName: z
      .string()
      .trim()
      .min(1, "Last name is required."),

    email: z
      .string()
      .trim()
      .email("Enter a valid email address."),
  }),

  professional: z.object({
    currentRole: z
      .string()
      .trim()
      .min(1, "Current role is required."),

    yearsOfExperience: z.coerce
      .number()
      .min(
        0,
        "Experience cannot be negative.",
      )
      .max(
        60,
        "Enter a realistic amount of experience.",
      ),
  }),

  consent: z.boolean().refine(
    (value) => value,
    {
      message:
        "You must confirm that the information is accurate.",
    },
  ),
});

export type ApplicationInput = z.input<
  typeof applicationSchema
>;

export type ApplicationOutput = z.output<
  typeof applicationSchema
>;
Enter fullscreen mode Exit fullscreen mode

Create ApplicationWizard.tsx:

import {
  useState,
} from "react";

import {
  FormProvider,
  useForm,
  useFormContext,
} from "react-hook-form";

import {
  zodResolver,
} from "@hookform/resolvers/zod";

import {
  applicationSchema,
} from "./application.schema";

import type {
  ApplicationInput,
  ApplicationOutput,
} from "./application.schema";

const defaultValues: ApplicationInput = {
  personal: {
    firstName: "",
    lastName: "",
    email: "",
  },

  professional: {
    currentRole: "",
    yearsOfExperience: 0,
  },

  consent: false,
};

const steps = [
  {
    title: "Personal information",

    fields: [
      "personal.firstName",
      "personal.lastName",
      "personal.email",
    ] as const,
  },

  {
    title: "Professional information",

    fields: [
      "professional.currentRole",
      "professional.yearsOfExperience",
    ] as const,
  },

  {
    title: "Review and submit",

    fields: [
      "consent",
    ] as const,
  },
];

function PersonalStep() {
  const {
    register,
    formState: {
      errors,
    },
  } = useFormContext<
    ApplicationInput
  >();

  return (
    <section>
      <h2>Personal information</h2>

      <div className="field">
        <label htmlFor="firstName">
          First name
        </label>

        <input
          id="firstName"
          autoComplete="given-name"
          aria-invalid={Boolean(
            errors.personal
              ?.firstName,
          )}
          aria-describedby={
            errors.personal
              ?.firstName
              ? "firstName-error"
              : undefined
          }
          {...register(
            "personal.firstName",
          )}
        />

        {errors.personal
          ?.firstName && (
          <p
            id="firstName-error"
            className="error"
          >
            {
              errors.personal
                .firstName
                .message
            }
          </p>
        )}
      </div>

      <div className="field">
        <label htmlFor="lastName">
          Last name
        </label>

        <input
          id="lastName"
          autoComplete="family-name"
          aria-invalid={Boolean(
            errors.personal
              ?.lastName,
          )}
          {...register(
            "personal.lastName",
          )}
        />

        {errors.personal
          ?.lastName && (
          <p className="error">
            {
              errors.personal
                .lastName
                .message
            }
          </p>
        )}
      </div>

      <div className="field">
        <label htmlFor="email">
          Email
        </label>

        <input
          id="email"
          type="email"
          autoComplete="email"
          aria-invalid={Boolean(
            errors.personal?.email,
          )}
          {...register(
            "personal.email",
          )}
        />

        {errors.personal?.email && (
          <p className="error">
            {
              errors.personal.email
                .message
            }
          </p>
        )}
      </div>
    </section>
  );
}

function ProfessionalStep() {
  const {
    register,
    formState: {
      errors,
    },
  } = useFormContext<
    ApplicationInput
  >();

  return (
    <section>
      <h2>
        Professional information
      </h2>

      <div className="field">
        <label htmlFor="currentRole">
          Current role
        </label>

        <input
          id="currentRole"
          aria-invalid={Boolean(
            errors.professional
              ?.currentRole,
          )}
          {...register(
            "professional.currentRole",
          )}
        />

        {errors.professional
          ?.currentRole && (
          <p className="error">
            {
              errors.professional
                .currentRole
                .message
            }
          </p>
        )}
      </div>

      <div className="field">
        <label htmlFor="experience">
          Years of experience
        </label>

        <input
          id="experience"
          type="number"
          min="0"
          max="60"
          aria-invalid={Boolean(
            errors.professional
              ?.yearsOfExperience,
          )}
          {...register(
            "professional.yearsOfExperience",
          )}
        />

        {errors.professional
          ?.yearsOfExperience && (
          <p className="error">
            {
              errors.professional
                .yearsOfExperience
                .message
            }
          </p>
        )}
      </div>
    </section>
  );
}

function ReviewStep() {
  const {
    register,
    getValues,
    formState: {
      errors,
    },
  } = useFormContext<
    ApplicationInput
  >();

  const values = getValues();

  return (
    <section>
      <h2>Review application</h2>

      <dl>
        <dt>Name</dt>
        <dd>
          {
            values.personal
              .firstName
          }{" "}
          {
            values.personal
              .lastName
          }
        </dd>

        <dt>Email</dt>
        <dd>
          {
            values.personal.email
          }
        </dd>

        <dt>Current role</dt>
        <dd>
          {
            values.professional
              .currentRole
          }
        </dd>

        <dt>Experience</dt>
        <dd>
          {
            values.professional
              .yearsOfExperience
          }{" "}
          years
        </dd>
      </dl>

      <label className="checkbox">
        <input
          type="checkbox"
          {...register("consent")}
        />

        <span>
          I confirm that this
          information is accurate.
        </span>
      </label>

      {errors.consent && (
        <p className="error">
          {errors.consent.message}
        </p>
      )}
    </section>
  );
}

export default function ApplicationWizard() {
  const [
    currentStep,
    setCurrentStep,
  ] = useState(0);

  const form =
    useForm<
      ApplicationInput,
      unknown,
      ApplicationOutput
    >({
      defaultValues,

      resolver:
        zodResolver(
          applicationSchema,
        ),

      mode: "onBlur",

      /*
       * Keep values from previous
       * steps after they unmount.
       */
      shouldUnregister: false,
    });

  const {
    handleSubmit,
    trigger,
    formState: {
      isSubmitting,
      isDirty,
    },
  } = form;

  async function goNext() {
    const fields =
      steps[currentStep].fields;

    const stepIsValid =
      await trigger(fields);

    if (!stepIsValid) {
      return;
    }

    setCurrentStep(
      (step) =>
        Math.min(
          step + 1,
          steps.length - 1,
        ),
    );
  }

  function goBack() {
    setCurrentStep(
      (step) =>
        Math.max(step - 1, 0),
    );
  }

  async function onSubmit(
    values: ApplicationOutput,
  ) {
    await new Promise(
      (resolve) => {
        setTimeout(
          resolve,
          1000,
        );
      },
    );

    console.log(
      "Submitted application:",
      values,
    );
  }

  return (
    <FormProvider {...form}>
      <form
        onSubmit={
          handleSubmit(onSubmit)
        }
        noValidate
      >
        <header>
          <p>
            Step {currentStep + 1} of{" "}
            {steps.length}
          </p>

          <h1>
            {
              steps[currentStep]
                .title
            }
          </h1>

          <progress
            value={
              currentStep + 1
            }
            max={steps.length}
          >
            {currentStep + 1} of{" "}
            {steps.length}
          </progress>
        </header>

        {currentStep === 0 && (
          <PersonalStep />
        )}

        {currentStep === 1 && (
          <ProfessionalStep />
        )}

        {currentStep === 2 && (
          <ReviewStep />
        )}

        <div className="actions">
          {currentStep > 0 && (
            <button
              type="button"
              onClick={goBack}
              disabled={
                isSubmitting
              }
            >
              Back
            </button>
          )}

          {currentStep <
          steps.length - 1 ? (
            <button
              type="button"
              onClick={goNext}
            >
              Continue
            </button>
          ) : (
            <button
              type="submit"
              disabled={
                isSubmitting
              }
            >
              {isSubmitting
                ? "Submitting..."
                : "Submit application"}
            </button>
          )}
        </div>

        {isDirty && (
          <p className="hint">
            You have unsaved
            changes.
          </p>
        )}
      </form>
    </FormProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why use FormProvider?

Without FormProvider, every step would need methods passed through props.

Wizard
├── register
├── errors
├── control
├── setValue
└── getValues
Enter fullscreen mode Exit fullscreen mode

FormProvider makes the form methods available through React context.

Nested step components can access them with:

const form =
  useFormContext<ApplicationInput>();
Enter fullscreen mode Exit fullscreen mode

Use this when components belong to the same logical form.

Do not automatically place one giant form provider around the entire application.


Validate only the current step

This code:

await trigger(
  steps[currentStep].fields,
);
Enter fullscreen mode Exit fullscreen mode

validates only the relevant fields.

Without it, clicking Continue might reveal errors in fields the user has not seen yet.

Step 1 Continue
       ↓
Validate Step 1
       ↓
Valid?
  /        \
No          Yes
↓            ↓
Show        Move to
errors      Step 2
Enter fullscreen mode Exit fullscreen mode

Advantages of multi-step forms

  • Reduces visible complexity
  • Improves organization
  • Works well on small screens
  • Allows step-specific validation
  • Supports draft saving
  • Makes progress easier to understand
  • Can collect data in a meaningful sequence

Disadvantages

  • More workflow state
  • Users may lose context between steps
  • Back navigation must preserve values
  • Validation becomes step-dependent
  • Progress persistence requires additional architecture
  • Too many steps can make a short form feel longer
  • Hidden-step accessibility must be considered

Senior engineer tip

Do not divide a form into steps merely to appear modern.

A form containing four simple fields is usually better on one page.

Use steps when the sections represent meaningful tasks.


17.2 Conditional Fields

A conditional field appears only when another answer makes it relevant.

Example:

Are you currently employed?

Yes
 └── Company name
 └── Job title
 └── Notice period

No
 └── These fields are hidden
Enter fullscreen mode Exit fullscreen mode

The problem

When a conditional field disappears, decide whether to:

  • Preserve its value
  • Remove its value
  • Remove its validation error
  • Submit it to the backend
  • Restore it if the user changes their answer again

These are product decisions.


Complete conditional-field example

import {
  useForm,
} from "react-hook-form";

interface EmploymentFormValues {
  employmentStatus:
    | ""
    | "employed"
    | "unemployed"
    | "student";

  companyName: string;
  jobTitle: string;
  collegeName: string;
}

export default function EmploymentForm() {
  const {
    register,
    handleSubmit,
    watch,
    formState: {
      errors,
    },
  } =
    useForm<EmploymentFormValues>({
      defaultValues: {
        employmentStatus: "",
        companyName: "",
        jobTitle: "",
        collegeName: "",
      },

      /*
       * Hidden fields are removed
       * from submitted form values.
       */
      shouldUnregister: true,
    });

  const employmentStatus =
    watch("employmentStatus");

  function onSubmit(
    values: EmploymentFormValues,
  ) {
    console.log(values);
  }

  return (
    <form
      onSubmit={
        handleSubmit(onSubmit)
      }
      noValidate
    >
      <fieldset>
        <legend>
          Employment status
        </legend>

        <label>
          <input
            type="radio"
            value="employed"
            {...register(
              "employmentStatus",
              {
                required:
                  "Select your employment status.",
              },
            )}
          />
          Employed
        </label>

        <label>
          <input
            type="radio"
            value="unemployed"
            {...register(
              "employmentStatus",
            )}
          />
          Unemployed
        </label>

        <label>
          <input
            type="radio"
            value="student"
            {...register(
              "employmentStatus",
            )}
          />
          Student
        </label>

        {errors
          .employmentStatus && (
          <p className="error">
            {
              errors
                .employmentStatus
                .message
            }
          </p>
        )}
      </fieldset>

      {employmentStatus ===
        "employed" && (
        <>
          <div className="field">
            <label htmlFor="companyName">
              Company name
            </label>

            <input
              id="companyName"
              {...register(
                "companyName",
                {
                  required:
                    "Company name is required.",
                },
              )}
            />

            {errors.companyName && (
              <p className="error">
                {
                  errors.companyName
                    .message
                }
              </p>
            )}
          </div>

          <div className="field">
            <label htmlFor="jobTitle">
              Job title
            </label>

            <input
              id="jobTitle"
              {...register(
                "jobTitle",
                {
                  required:
                    "Job title is required.",
                },
              )}
            />

            {errors.jobTitle && (
              <p className="error">
                {
                  errors.jobTitle
                    .message
                }
              </p>
            )}
          </div>
        </>
      )}

      {employmentStatus ===
        "student" && (
        <div className="field">
          <label htmlFor="collegeName">
            College name
          </label>

          <input
            id="collegeName"
            {...register(
              "collegeName",
              {
                required:
                  "College name is required.",
              },
            )}
          />

          {errors.collegeName && (
            <p className="error">
              {
                errors.collegeName
                  .message
              }
            </p>
          )}
        </div>
      )}

      <button type="submit">
        Save
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

shouldUnregister decision

Use:

shouldUnregister: true
Enter fullscreen mode Exit fullscreen mode

when hidden fields should be removed.

Use:

shouldUnregister: false
Enter fullscreen mode Exit fullscreen mode

when hidden sections should retain progress.

Example:

Checkout shipping address
→ Preserve when temporarily hidden

Employment fields after selecting unemployed
→ Usually remove from submitted data
Enter fullscreen mode Exit fullscreen mode

Advantages

  • Users see only relevant questions
  • Reduces unnecessary work
  • Produces cleaner workflows
  • Can improve completion rates
  • Supports personalized forms

Disadvantages

  • Hidden data may remain accidentally
  • Errors can remain after fields disappear
  • Conditional rules can become hard to understand
  • Accessibility announcements may be needed
  • Complex dependency chains create maintenance risk

17.3 Dynamic Arrays with useFieldArray

Dynamic arrays allow users to add repeated groups.

Examples:

  • Work experiences
  • Education records
  • Emergency contacts
  • Product items
  • Addresses
  • Interview questions
  • Team members

React Hook Form provides useFieldArray() for appending, removing, inserting, moving, and replacing repeated field groups.


The problem

Suppose a candidate adds three experiences.

{
  experiences: [
    {
      company: "Company A",
      role: "Intern"
    },
    {
      company: "Company B",
      role: "Developer"
    },
    {
      company: "Company C",
      role: "Engineer"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Each item requires:

  • A stable identity
  • Nested registration paths
  • Error mapping
  • Add and remove operations
  • Correct rendering after reordering

Using only the array index as the React key can cause incorrect UI reuse after removing or moving items.

useFieldArray() returns field objects containing stable generated identifiers for rendering.


Complete work-experience example

import {
  useFieldArray,
  useForm,
} from "react-hook-form";

interface Experience {
  company: string;
  role: string;
  startDate: string;
  endDate: string;
  currentlyWorking: boolean;
}

interface ResumeFormValues {
  experiences: Experience[];
}

const emptyExperience:
  Experience = {
    company: "",
    role: "",
    startDate: "",
    endDate: "",
    currentlyWorking: false,
  };

export default function ExperienceForm() {
  const {
    register,
    control,
    handleSubmit,
    watch,
    formState: {
      errors,
    },
  } =
    useForm<ResumeFormValues>({
      defaultValues: {
        experiences: [
          emptyExperience,
        ],
      },
    });

  const {
    fields,
    append,
    remove,
    move,
  } = useFieldArray({
    control,
    name: "experiences",
  });

  const experiences =
    watch("experiences");

  function onSubmit(
    values: ResumeFormValues,
  ) {
    console.log(values);
  }

  return (
    <form
      onSubmit={
        handleSubmit(onSubmit)
      }
      noValidate
    >
      <h1>Work experience</h1>

      {fields.map(
        (field, index) => {
          const currentlyWorking =
            experiences?.[index]
              ?.currentlyWorking;

          const experienceErrors =
            errors.experiences
              ?.[index];

          return (
            <fieldset
              key={field.id}
            >
              <legend>
                Experience{" "}
                {index + 1}
              </legend>

              <div className="field">
                <label
                  htmlFor={`company-${index}`}
                >
                  Company
                </label>

                <input
                  id={`company-${index}`}
                  {...register(
                    `experiences.${index}.company`,
                    {
                      required:
                        "Company is required.",
                    },
                  )}
                />

                {experienceErrors
                  ?.company && (
                  <p className="error">
                    {
                      experienceErrors
                        .company
                        .message
                    }
                  </p>
                )}
              </div>

              <div className="field">
                <label
                  htmlFor={`role-${index}`}
                >
                  Role
                </label>

                <input
                  id={`role-${index}`}
                  {...register(
                    `experiences.${index}.role`,
                    {
                      required:
                        "Role is required.",
                    },
                  )}
                />

                {experienceErrors
                  ?.role && (
                  <p className="error">
                    {
                      experienceErrors
                        .role
                        .message
                    }
                  </p>
                )}
              </div>

              <div className="field">
                <label
                  htmlFor={`start-${index}`}
                >
                  Start date
                </label>

                <input
                  id={`start-${index}`}
                  type="month"
                  {...register(
                    `experiences.${index}.startDate`,
                    {
                      required:
                        "Start date is required.",
                    },
                  )}
                />
              </div>

              <label>
                <input
                  type="checkbox"
                  {...register(
                    `experiences.${index}.currentlyWorking`,
                  )}
                />

                I currently work here
              </label>

              {!currentlyWorking && (
                <div className="field">
                  <label
                    htmlFor={`end-${index}`}
                  >
                    End date
                  </label>

                  <input
                    id={`end-${index}`}
                    type="month"
                    {...register(
                      `experiences.${index}.endDate`,
                      {
                        required:
                          "End date is required.",
                      },
                    )}
                  />

                  {experienceErrors
                    ?.endDate && (
                    <p className="error">
                      {
                        experienceErrors
                          .endDate
                          .message
                      }
                    </p>
                  )}
                </div>
              )}

              <div className="actions">
                <button
                  type="button"
                  onClick={() => {
                    remove(index);
                  }}
                  disabled={
                    fields.length ===
                    1
                  }
                >
                  Remove
                </button>

                {index > 0 && (
                  <button
                    type="button"
                    onClick={() => {
                      move(
                        index,
                        index - 1,
                      );
                    }}
                  >
                    Move up
                  </button>
                )}
              </div>
            </fieldset>
          );
        },
      )}

      <button
        type="button"
        onClick={() => {
          append({
            ...emptyExperience,
          });
        }}
      >
        Add experience
      </button>

      <button type="submit">
        Save experiences
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why use field.id as the React key?

Correct:

key={field.id}
Enter fullscreen mode Exit fullscreen mode

Risky:

key={index}
Enter fullscreen mode Exit fullscreen mode

When an item is removed, array indexes change.

React may reuse the wrong rendered element if the index is treated as its identity.

The generated field ID remains stable for that item.


Dynamic error paths

For the second experience's company:

errors.experiences?.[1]?.company
Enter fullscreen mode Exit fullscreen mode

The path is:

experiences
    ↓
index 1
    ↓
company
Enter fullscreen mode Exit fullscreen mode

This mirrors the form value structure.


Advantages

  • Supports repeated groups
  • Stable item identifiers
  • Built-in array operations
  • Nested error support
  • Better performance than rebuilding array state manually
  • Works naturally with form submission

Disadvantages

  • Nested TypeScript paths can become complex
  • Index-based server errors must map correctly
  • Reordering may affect business identifiers
  • Large arrays can produce long pages
  • Removing items may require confirmation
  • Backend IDs and frontend generated IDs must not be confused

Senior engineer tip

The generated field.id is a UI identity.

A database record may also have:

databaseId: string;
Enter fullscreen mode Exit fullscreen mode

Do not assume they are the same.

{
  id: "react-hook-form-render-key",
  databaseId: "persistent-database-record"
}
Enter fullscreen mode Exit fullscreen mode

17.4 Nested Objects

Form structures should normally resemble the domain data they produce.

Instead of:

{
  addressCountry: "India",
  addressState: "Andhra Pradesh",
  addressCity: "Kadapa"
}
Enter fullscreen mode Exit fullscreen mode

you may use:

{
  address: {
    country: "India",
    state: "Andhra Pradesh",
    city: "Kadapa"
  }
}
Enter fullscreen mode Exit fullscreen mode

Register nested fields using path notation:

<input
  {...register(
    "address.country",
  )}
/>

<input
  {...register(
    "address.state",
  )}
/>
Enter fullscreen mode Exit fullscreen mode

Errors follow the same shape:

errors.address?.country
Enter fullscreen mode Exit fullscreen mode

Complete nested-object example

import {
  useForm,
} from "react-hook-form";

interface CheckoutValues {
  customer: {
    firstName: string;
    lastName: string;
    email: string;
  };

  shippingAddress: {
    street: string;
    city: string;
    state: string;
    postalCode: string;
    country: string;
  };
}

export default function CheckoutForm() {
  const {
    register,
    handleSubmit,
    formState: {
      errors,
    },
  } =
    useForm<CheckoutValues>({
      defaultValues: {
        customer: {
          firstName: "",
          lastName: "",
          email: "",
        },

        shippingAddress: {
          street: "",
          city: "",
          state: "",
          postalCode: "",
          country: "",
        },
      },
    });

  return (
    <form
      onSubmit={handleSubmit(
        console.log,
      )}
    >
      <fieldset>
        <legend>
          Customer details
        </legend>

        <input
          placeholder="First name"
          {...register(
            "customer.firstName",
            {
              required:
                "First name is required.",
            },
          )}
        />

        {errors.customer
          ?.firstName && (
          <p className="error">
            {
              errors.customer
                .firstName.message
            }
          </p>
        )}

        <input
          placeholder="Last name"
          {...register(
            "customer.lastName",
            {
              required:
                "Last name is required.",
            },
          )}
        />

        <input
          type="email"
          placeholder="Email"
          {...register(
            "customer.email",
            {
              required:
                "Email is required.",
            },
          )}
        />
      </fieldset>

      <fieldset>
        <legend>
          Shipping address
        </legend>

        <input
          placeholder="Street"
          autoComplete="shipping street-address"
          {...register(
            "shippingAddress.street",
            {
              required:
                "Street is required.",
            },
          )}
        />

        <input
          placeholder="City"
          autoComplete="shipping address-level2"
          {...register(
            "shippingAddress.city",
            {
              required:
                "City is required.",
            },
          )}
        />

        <input
          placeholder="State"
          autoComplete="shipping address-level1"
          {...register(
            "shippingAddress.state",
            {
              required:
                "State is required.",
            },
          )}
        />

        <input
          placeholder="Postal code"
          autoComplete="shipping postal-code"
          {...register(
            "shippingAddress.postalCode",
            {
              required:
                "Postal code is required.",
            },
          )}
        />

        <input
          placeholder="Country"
          autoComplete="shipping country-name"
          {...register(
            "shippingAddress.country",
            {
              required:
                "Country is required.",
            },
          )}
        />
      </fieldset>

      <button type="submit">
        Continue
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Browsers can use specific autocomplete tokens as hints for prefilling names, addresses, postal codes, and other recognized values.


17.5 File Uploads

File fields differ from ordinary text inputs.

A text field produces:

email: string
Enter fullscreen mode Exit fullscreen mode

A file input produces a FileList.

<input
  type="file"
  {...register("resume")}
/>
Enter fullscreen mode Exit fullscreen mode

The selected files are accessible through the browser's file-input APIs. A file input can support multiple files, while the files property contains the selected File objects.


The problem

A production upload may require:

  • Type validation
  • Size validation
  • Preview
  • Upload progress
  • Cancellation
  • Retry
  • Direct cloud upload
  • Virus scanning
  • Server verification
  • Persistent file metadata

The HTML accept attribute helps filter the file picker, but it is only a hint and must not replace server validation.


Complete resume-upload example

import {
  useState,
} from "react";

import {
  useForm,
} from "react-hook-form";

interface ResumeFormValues {
  candidateName: string;
  resume: FileList;
}

const MAX_FILE_SIZE =
  5 * 1024 * 1024;

const allowedTypes = [
  "application/pdf",
];

export default function ResumeUploadForm() {
  const [
    selectedFileName,
    setSelectedFileName,
  ] = useState("");

  const {
    register,
    handleSubmit,
    setError,
    clearErrors,
    reset,
    formState: {
      errors,
      isSubmitting,
    },
  } =
    useForm<ResumeFormValues>();

  const resumeRegistration =
    register("resume", {
      required:
        "Select a resume.",

      validate: {
        oneFile: (
          files,
        ) =>
          files.length === 1 ||
          "Select exactly one file.",

        allowedType: (
          files,
        ) => {
          const file =
            files.item(0);

          return (
            !file ||
            allowedTypes.includes(
              file.type,
            ) ||
            "Only PDF files are allowed."
          );
        },

        fileSize: (
          files,
        ) => {
          const file =
            files.item(0);

          return (
            !file ||
            file.size <=
              MAX_FILE_SIZE ||
            "The resume must be 5 MB or smaller."
          );
        },
      },
    });

  async function onSubmit(
    values: ResumeFormValues,
  ) {
    const resume =
      values.resume.item(0);

    if (!resume) {
      setError("resume", {
        type: "manual",
        message:
          "Select a resume.",
      });

      return;
    }

    const body =
      new FormData();

    body.append(
      "candidateName",
      values.candidateName,
    );

    body.append(
      "resume",
      resume,
    );

    const response =
      await fetch(
        "/api/applications",
        {
          method: "POST",
          body,
        },
      );

    if (!response.ok) {
      setError(
        "root.server",
        {
          type: "server",
          message:
            "The resume could not be uploaded.",
        },
      );

      return;
    }

    reset();
    setSelectedFileName("");
  }

  return (
    <form
      onSubmit={
        handleSubmit(onSubmit)
      }
      noValidate
    >
      <div className="field">
        <label htmlFor="candidateName">
          Candidate name
        </label>

        <input
          id="candidateName"
          {...register(
            "candidateName",
            {
              required:
                "Candidate name is required.",
            },
          )}
        />

        {errors.candidateName && (
          <p className="error">
            {
              errors.candidateName
                .message
            }
          </p>
        )}
      </div>

      <div className="field">
        <label htmlFor="resume">
          Resume
        </label>

        <input
          id="resume"
          type="file"
          accept=".pdf,application/pdf"
          aria-describedby="resume-hint resume-error"
          {...resumeRegistration}
          onChange={(event) => {
            resumeRegistration
              .onChange(event);

            clearErrors("resume");

            setSelectedFileName(
              event.target.files
                ?.item(0)?.name ??
                "",
            );
          }}
        />

        <p id="resume-hint">
          Upload one PDF, maximum
          size 5 MB.
        </p>

        {selectedFileName && (
          <p>
            Selected:{" "}
            {selectedFileName}
          </p>
        )}

        {errors.resume && (
          <p
            id="resume-error"
            className="error"
          >
            {String(
              errors.resume
                .message,
            )}
          </p>
        )}
      </div>

      {errors.root?.server && (
        <p
          className="error"
          role="alert"
        >
          {
            errors.root.server
              .message
          }
        </p>
      )}

      <button
        type="submit"
        disabled={isSubmitting}
      >
        {isSubmitting
          ? "Uploading..."
          : "Submit application"}
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Do not manually set multipart content type

When sending FormData, avoid:

headers: {
  "Content-Type":
    "multipart/form-data"
}
Enter fullscreen mode Exit fullscreen mode

The browser must generate the multipart boundary.

Use:

fetch("/api/upload", {
  method: "POST",
  body: formData,
});
Enter fullscreen mode Exit fullscreen mode

Server validation is mandatory

The backend must verify:

  • Actual file size
  • Actual content type
  • File signature where needed
  • Authorized uploader
  • Number of files
  • Storage path
  • Malware-scanning result
  • Whether the file is expected by the workflow

Client-side checks improve feedback but are not trustworthy.


17.6 Drag-and-Drop Uploads

Drag and drop should enhance a normal file input, not replace it.

A hidden or visually customized file input still provides:

  • Keyboard access
  • Native file picker
  • Screen-reader semantics
  • Mobile support

Complete drop-zone example

import {
  DragEvent,
  useRef,
  useState,
} from "react";

interface DropZoneProps {
  onFileSelected:
    (file: File) => void;
  acceptLabel: string;
}

export default function DropZone({
  onFileSelected,
  acceptLabel,
}: DropZoneProps) {
  const inputRef =
    useRef<HTMLInputElement>(
      null,
    );

  const [
    isDragging,
    setIsDragging,
  ] = useState(false);

  function selectFile(
    files:
      | FileList
      | null,
  ) {
    const file =
      files?.item(0);

    if (file) {
      onFileSelected(file);
    }
  }

  function handleDrop(
    event:
      DragEvent<HTMLDivElement>,
  ) {
    event.preventDefault();

    setIsDragging(false);

    selectFile(
      event.dataTransfer.files,
    );
  }

  return (
    <div>
      <input
        ref={inputRef}
        id="resume"
        type="file"
        accept=".pdf,application/pdf"
        onChange={(event) => {
          selectFile(
            event.target.files,
          );
        }}
      />

      <div
        className={
          isDragging
            ? "drop-zone active"
            : "drop-zone"
        }
        onDragEnter={(
          event,
        ) => {
          event.preventDefault();
          setIsDragging(true);
        }}
        onDragOver={(event) => {
          event.preventDefault();
        }}
        onDragLeave={() => {
          setIsDragging(false);
        }}
        onDrop={handleDrop}
      >
        <p>
          Drag your resume here
          or use the file picker.
        </p>

        <button
          type="button"
          onClick={() => {
            inputRef.current
              ?.click();
          }}
        >
          Choose file
        </button>

        <p>{acceptLabel}</p>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The real file input remains available.

The drop zone is an additional interaction method.


17.7 Debounced Validation

Some validation requires a server request.

Examples:

  • Username availability
  • Coupon validity
  • Organization slug availability
  • Tax number verification
  • Address suggestions

Sending a request on every keystroke is wasteful.

k
ka
kar
kart
karth
karthik
Enter fullscreen mode Exit fullscreen mode

Without debouncing, that could produce seven requests.


Complete debounced username check

import {
  useEffect,
  useState,
} from "react";

interface UsernameStatus {
  state:
    | "idle"
    | "checking"
    | "available"
    | "unavailable"
    | "error";

  message: string;
}

export function useUsernameCheck(
  username: string,
): UsernameStatus {
  const [
    status,
    setStatus,
  ] =
    useState<UsernameStatus>({
      state: "idle",
      message: "",
    });

  useEffect(() => {
    const normalized =
      username
        .trim()
        .toLowerCase();

    if (
      normalized.length < 3
    ) {
      setStatus({
        state: "idle",
        message: "",
      });

      return;
    }

    const controller =
      new AbortController();

    const timeoutId =
      window.setTimeout(
        async () => {
          setStatus({
            state:
              "checking",
            message:
              "Checking availability...",
          });

          try {
            const response =
              await fetch(
                `/api/usernames/${encodeURIComponent(
                  normalized,
                )}/availability`,
                {
                  signal:
                    controller.signal,
                },
              );

            if (!response.ok) {
              throw new Error(
                "Availability request failed.",
              );
            }

            const result =
              (await response.json()) as {
                available: boolean;
              };

            setStatus(
              result.available
                ? {
                    state:
                      "available",
                    message:
                      "Username is available.",
                  }
                : {
                    state:
                      "unavailable",
                    message:
                      "Username is already taken.",
                  },
            );
          } catch (error) {
            if (
              error instanceof
                DOMException &&
              error.name ===
                "AbortError"
            ) {
              return;
            }

            setStatus({
              state: "error",
              message:
                "Unable to check availability.",
            });
          }
        },
        400,
      );

    return () => {
      window.clearTimeout(
        timeoutId,
      );

      controller.abort();
    };
  }, [username]);

  return status;
}
Enter fullscreen mode Exit fullscreen mode

Usage:

import {
  useForm,
  useWatch,
} from "react-hook-form";

interface UsernameFormValues {
  username: string;
}

export default function UsernameForm() {
  const {
    register,
    control,
    setError,
    clearErrors,
    handleSubmit,
  } =
    useForm<UsernameFormValues>({
      defaultValues: {
        username: "",
      },
    });

  const username =
    useWatch({
      control,
      name: "username",
    });

  const status =
    useUsernameCheck(username);

  useEffect(() => {
    if (
      status.state ===
      "unavailable"
    ) {
      setError("username", {
        type: "server",
        message:
          status.message,
      });
    }

    if (
      status.state ===
      "available"
    ) {
      clearErrors("username");
    }
  }, [
    status,
    setError,
    clearErrors,
  ]);

  return (
    <form
      onSubmit={handleSubmit(
        console.log,
      )}
    >
      <input
        {...register("username")}
      />

      <p
        role="status"
        aria-live="polite"
      >
        {status.message}
      </p>

      <button type="submit">
        Continue
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Race-condition protection

Debouncing reduces requests.

Aborting prevents an old request from overwriting a newer answer.

Request: "kart"
Request: "karthik"

"karthik" response arrives
        ↓
"kart" response arrives later
Enter fullscreen mode Exit fullscreen mode

Without cancellation or request-version checking, the old result may incorrectly replace the current result.


Never rely on availability checks as a guarantee

A username may be available during typing and taken one second later.

The backend must perform the authoritative check during submission.


17.8 Autosave and Draft Saving

Autosave stores progress while the user is editing.

Draft saving is valuable for:

  • Long applications
  • Content editors
  • Medical or government forms
  • Multi-step onboarding
  • Surveys
  • Complex business records

The autosave state machine

Idle
  ↓
User edits
  ↓
Unsaved
  ↓
Saving
  ├──→ Saved
  └──→ Failed
Enter fullscreen mode Exit fullscreen mode

A production UI should communicate this state.

Unsaved changes
Saving...
Saved at 11:42
Could not save draft
Enter fullscreen mode Exit fullscreen mode

Local draft example

import {
  useEffect,
} from "react";

import {
  useForm,
  useWatch,
} from "react-hook-form";

interface DraftValues {
  title: string;
  description: string;
  notes: string;
}

const STORAGE_KEY =
  "project-draft-v1";

function loadDraft():
  DraftValues {
  const stored =
    window.localStorage
      .getItem(STORAGE_KEY);

  if (!stored) {
    return {
      title: "",
      description: "",
      notes: "",
    };
  }

  try {
    return JSON.parse(
      stored,
    ) as DraftValues;
  } catch {
    return {
      title: "",
      description: "",
      notes: "",
    };
  }
}

export default function DraftForm() {
  const {
    register,
    control,
    handleSubmit,
    reset,
  } =
    useForm<DraftValues>({
      defaultValues:
        loadDraft(),
    });

  const values =
    useWatch({
      control,
    });

  useEffect(() => {
    const timeoutId =
      window.setTimeout(
        () => {
          window.localStorage
            .setItem(
              STORAGE_KEY,
              JSON.stringify(
                values,
              ),
            );
        },
        500,
      );

    return () => {
      window.clearTimeout(
        timeoutId,
      );
    };
  }, [values]);

  async function onSubmit(
    submittedValues:
      DraftValues,
  ) {
    console.log(
      submittedValues,
    );

    window.localStorage
      .removeItem(
        STORAGE_KEY,
      );

    reset();
  }

  return (
    <form
      onSubmit={
        handleSubmit(onSubmit)
      }
    >
      <input
        placeholder="Title"
        {...register("title")}
      />

      <textarea
        placeholder="Description"
        {...register(
          "description",
        )}
      />

      <textarea
        placeholder="Notes"
        {...register("notes")}
      />

      <button type="submit">
        Publish
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Security warning

Do not place highly sensitive data in localStorage.

Examples that require stronger protection include:

  • Passwords
  • Authentication tokens
  • Payment details
  • Confidential health information
  • Highly sensitive identity documents

Local storage is readable by JavaScript running on the same origin.

For sensitive drafts, prefer authenticated server-side storage with appropriate access control and encryption.


Server-side autosave hook

import {
  useEffect,
  useRef,
  useState,
} from "react";

interface AutosaveOptions<T> {
  values: T;
  enabled: boolean;
  save:
    (
      values: T,
      signal: AbortSignal,
    ) => Promise<void>;
  delay?: number;
}

export function useAutosave<T>({
  values,
  enabled,
  save,
  delay = 1000,
}: AutosaveOptions<T>) {
  const [
    status,
    setStatus,
  ] = useState<
    | "idle"
    | "unsaved"
    | "saving"
    | "saved"
    | "error"
  >("idle");

  const firstRender =
    useRef(true);

  useEffect(() => {
    if (
      firstRender.current
    ) {
      firstRender.current =
        false;

      return;
    }

    if (!enabled) {
      return;
    }

    setStatus("unsaved");

    const controller =
      new AbortController();

    const timeoutId =
      window.setTimeout(
        async () => {
          setStatus("saving");

          try {
            await save(
              values,
              controller.signal,
            );

            setStatus("saved");
          } catch (error) {
            if (
              error instanceof
                DOMException &&
              error.name ===
                "AbortError"
            ) {
              return;
            }

            setStatus("error");
          }
        },
        delay,
      );

    return () => {
      window.clearTimeout(
        timeoutId,
      );

      controller.abort();
    };
  }, [
    values,
    enabled,
    save,
    delay,
  ]);

  return status;
}
Enter fullscreen mode Exit fullscreen mode

Autosave design questions

Before implementing autosave, answer:

  • What is the draft identifier?
  • Does each user have one draft or many?
  • How are concurrent browser tabs handled?
  • What happens when the server version is newer?
  • Can offline edits be merged?
  • How are conflicts presented?
  • How long are drafts retained?
  • Which fields are excluded from saving?
  • Does the user know their data is being stored?

Autosave is a data-consistency feature, not only a timer.


17.9 Optimistic UI

Optimistic UI updates the interface before the server confirms success.

Example:

User adds a skill
       ↓
Skill appears immediately
       ↓
Request is sent
       ↓
Success → keep it
Failure → remove it and show error
Enter fullscreen mode Exit fullscreen mode

Complete optimistic skill example

import {
  useState,
} from "react";

interface Skill {
  id: string;
  name: string;
  status:
    | "saving"
    | "saved"
    | "failed";
}

export default function SkillsEditor() {
  const [
    skills,
    setSkills,
  ] = useState<Skill[]>([]);

  const [
    input,
    setInput,
  ] = useState("");

  async function addSkill() {
    const name =
      input.trim();

    if (!name) {
      return;
    }

    const temporaryId =
      crypto.randomUUID();

    const optimisticSkill:
      Skill = {
        id: temporaryId,
        name,
        status: "saving",
      };

    setSkills(
      (currentSkills) => [
        ...currentSkills,
        optimisticSkill,
      ],
    );

    setInput("");

    try {
      const response =
        await fetch(
          "/api/skills",
          {
            method: "POST",

            headers: {
              "Content-Type":
                "application/json",
            },

            body:
              JSON.stringify({
                name,
              }),
          },
        );

      if (!response.ok) {
        throw new Error(
          "Unable to save skill.",
        );
      }

      const result =
        (await response.json()) as {
          skill: {
            id: string;
            name: string;
          };
        };

      setSkills(
        (currentSkills) =>
          currentSkills.map(
            (skill) =>
              skill.id ===
              temporaryId
                ? {
                    ...result.skill,
                    status:
                      "saved",
                  }
                : skill,
          ),
      );
    } catch {
      setSkills(
        (currentSkills) =>
          currentSkills.map(
            (skill) =>
              skill.id ===
              temporaryId
                ? {
                    ...skill,
                    status:
                      "failed",
                  }
                : skill,
          ),
      );
    }
  }

  return (
    <section>
      <h2>Skills</h2>

      <input
        value={input}
        onChange={(event) => {
          setInput(
            event.target.value,
          );
        }}
      />

      <button
        type="button"
        onClick={addSkill}
      >
        Add skill
      </button>

      <ul>
        {skills.map((skill) => (
          <li key={skill.id}>
            {skill.name}

            {skill.status ===
              "saving" && (
              <span>
                {" "}
                Saving...
              </span>
            )}

            {skill.status ===
              "failed" && (
              <span
                className="error"
              >
                {" "}
                Could not save
              </span>
            )}
          </li>
        ))}
      </ul>
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

When optimistic UI is appropriate

Good candidates:

  • Adding a tag
  • Toggling a preference
  • Liking an item
  • Reordering a list
  • Updating low-risk profile metadata

Riskier candidates:

  • Final payment confirmation
  • Legal acceptance
  • Account deletion
  • Irreversible operations
  • Limited inventory allocation
  • High-value financial actions

The interface must not claim success before confirmation when incorrect success could cause serious harm.


Enterprise Form Advantages

  • Handles complex workflows
  • Supports large domain objects
  • Preserves long-running progress
  • Improves organization
  • Supports dynamic user needs
  • Enables richer feedback
  • Provides reusable infrastructure

Enterprise Form Disadvantages

  • More state transitions
  • More backend endpoints
  • More concurrency problems
  • Larger testing surface
  • Accessibility becomes harder
  • Draft and autosave security must be designed
  • Error handling becomes distributed
  • More opportunities for stale state

Why this evolved

Modern products require forms to behave like complete applications. Developers therefore moved from individual components toward feature-based form architecture with schemas, API layers, shared UI components, testing, observability, and explicit workflow state.


Stage 18: Modern Production Form Architecture

A production form should not normally live in one 1,000-line component.

A large component often mixes:

UI markup
Validation schema
API requests
Error conversion
Analytics
Navigation
File upload logic
Draft saving
Business rules
Enter fullscreen mode Exit fullscreen mode

This makes changes dangerous.

A better architecture separates responsibilities.


Recommended feature structure

src/
├── components/
│   └── forms/
│       ├── FormField.tsx
│       ├── FormError.tsx
│       ├── ErrorSummary.tsx
│       ├── SubmitButton.tsx
│       └── FileField.tsx
│
├── features/
│   └── applications/
│       ├── api/
│       │   ├── create-application.ts
│       │   ├── save-draft.ts
│       │   └── upload-resume.ts
│       │
│       ├── components/
│       │   ├── ApplicationForm.tsx
│       │   ├── PersonalStep.tsx
│       │   ├── ExperienceStep.tsx
│       │   └── ReviewStep.tsx
│       │
│       ├── hooks/
│       │   ├── use-application-draft.ts
│       │   └── use-resume-upload.ts
│       │
│       ├── schemas/
│       │   └── application.schema.ts
│       │
│       ├── types/
│       │   └── application.types.ts
│       │
│       └── utils/
│           └── map-api-errors.ts
│
├── lib/
│   ├── api-client.ts
│   └── logger.ts
│
└── app/
    └── applications/
        └── page.tsx
Enter fullscreen mode Exit fullscreen mode

Responsibility boundaries

Schema

What input shape is valid?
Enter fullscreen mode Exit fullscreen mode

Form component

How does the user interact with it?
Enter fullscreen mode Exit fullscreen mode

API function

How is data sent to the server?
Enter fullscreen mode Exit fullscreen mode

Error mapper

How do server errors become field errors?
Enter fullscreen mode Exit fullscreen mode

Reusable field component

How are labels, hints, inputs,
and errors rendered consistently?
Enter fullscreen mode Exit fullscreen mode

Page or route

Where does the user go after success?
Enter fullscreen mode Exit fullscreen mode

18.1 Reusable FormField

A reusable component should manage presentation and accessibility.

It should not own business validation.

import {
  forwardRef,
} from "react";

import type {
  InputHTMLAttributes,
  ReactNode,
} from "react";

interface FormFieldProps
  extends InputHTMLAttributes<HTMLInputElement> {
  id: string;
  label: string;
  error?: string;
  hint?: ReactNode;
}

const FormField =
  forwardRef<
    HTMLInputElement,
    FormFieldProps
  >(function FormField(
    {
      id,
      label,
      error,
      hint,
      required,
      ...inputProps
    },
    ref,
  ) {
    const hintId =
      hint
        ? `${id}-hint`
        : undefined;

    const errorId =
      error
        ? `${id}-error`
        : undefined;

    const describedBy = [
      hintId,
      errorId,
    ]
      .filter(Boolean)
      .join(" ") || undefined;

    return (
      <div className="field">
        <label htmlFor={id}>
          {label}

          {required && (
            <span
              aria-hidden="true"
            >
              {" *"}
            </span>
          )}
        </label>

        <input
          {...inputProps}
          id={id}
          ref={ref}
          required={required}
          aria-invalid={Boolean(
            error,
          )}
          aria-describedby={
            describedBy
          }
        />

        {hint && (
          <p
            id={hintId}
            className="hint"
          >
            {hint}
          </p>
        )}

        {error && (
          <p
            id={errorId}
            className="error"
          >
            {error}
          </p>
        )}
      </div>
    );
  });

export default FormField;
Enter fullscreen mode Exit fullscreen mode

What this component should handle

  • Label association
  • Hint IDs
  • Error IDs
  • aria-invalid
  • aria-describedby
  • Consistent styling
  • Ref forwarding

What it should not handle

  • Email uniqueness
  • API requests
  • Redirects
  • Password policies
  • Database rules
  • Toast notifications

18.2 Reusable Form Error

interface FormErrorProps {
  message?: string;
}

export default function FormError({
  message,
}: FormErrorProps) {
  if (!message) {
    return null;
  }

  return (
    <div
      className="form-error"
      role="alert"
    >
      <strong>
        We could not submit
        the form.
      </strong>

      <p>{message}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Use field errors beside fields.

Use FormError for form-wide failures.


18.3 Schema Organization

Small feature:

register.schema.ts
Enter fullscreen mode Exit fullscreen mode

Large feature:

schemas/
├── personal.schema.ts
├── experience.schema.ts
├── document.schema.ts
└── application.schema.ts
Enter fullscreen mode Exit fullscreen mode

Composable schemas:

import { z } from "zod";

export const personalSchema =
  z.object({
    firstName:
      z.string().min(1),

    lastName:
      z.string().min(1),

    email:
      z.string().email(),
  });

export const experienceSchema =
  z.object({
    company:
      z.string().min(1),

    role:
      z.string().min(1),

    startDate:
      z.string().min(1),

    endDate:
      z.string().optional(),
  });

export const applicationSchema =
  z.object({
    personal:
      personalSchema,

    experiences:
      z
        .array(
          experienceSchema,
        )
        .min(
          1,
          "Add at least one experience.",
        ),
  });
Enter fullscreen mode Exit fullscreen mode

18.4 API Layer

Bad:

async function onSubmit(values) {
  const response =
    await fetch(...);

  if (response.status === 409) {
    // Parse backend
  }

  if (response.status === 401) {
    // Refresh session
  }

  if (response.status === 500) {
    // Log error
  }
}
Enter fullscreen mode Exit fullscreen mode

This becomes duplicated across forms.

Create an API client.

export class HttpError<
  TData = unknown,
> extends Error {
  status: number;
  data: TData;

  constructor(
    status: number,
    data: TData,
    message:
      string = "Request failed.",
  ) {
    super(message);

    this.name =
      "HttpError";

    this.status = status;
    this.data = data;
  }
}

export async function apiRequest<
  TResponse,
>(
  url: string,
  options?: RequestInit,
): Promise<TResponse> {
  const response =
    await fetch(url, {
      ...options,

      headers: {
        "Content-Type":
          "application/json",

        ...options?.headers,
      },
    });

  const data =
    (await response.json()) as
      | TResponse
      | {
          message?: string;
        };

  if (!response.ok) {
    throw new HttpError(
      response.status,
      data,
      "message" in data
        ? data.message
        : "Request failed.",
    );
  }

  return data as TResponse;
}
Enter fullscreen mode Exit fullscreen mode

Feature API function:

import type {
  ApplicationOutput,
} from "../schemas/application.schema";

interface CreateApplicationResponse {
  success: true;

  data: {
    applicationId: string;
  };
}

export function createApplication(
  input: ApplicationOutput,
) {
  return apiRequest<
    CreateApplicationResponse
  >(
    "/api/applications",
    {
      method: "POST",

      body:
        JSON.stringify(input),
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

The form does not need to know every Fetch detail.


18.5 Loading States

Loading is not one universal Boolean.

A complex form may contain:

isLoadingInitialData
isCheckingUsername
isSavingDraft
isUploadingResume
isSubmitting
isRedirecting
Enter fullscreen mode Exit fullscreen mode

Do not combine every operation into:

const [loading, setLoading] =
  useState(false);
Enter fullscreen mode Exit fullscreen mode

Use specific states.

interface ApplicationStatus {
  draft:
    | "idle"
    | "saving"
    | "saved"
    | "error";

  resume:
    | "idle"
    | "uploading"
    | "uploaded"
    | "error";

  submission:
    | "idle"
    | "submitting"
    | "success"
    | "error";
}
Enter fullscreen mode Exit fullscreen mode

This prevents invalid UI behavior.


18.6 Toast Notifications

Toasts are useful for global, temporary feedback.

Good toast examples:

Draft saved.
Profile updated.
Invitation sent.
File uploaded.
Enter fullscreen mode Exit fullscreen mode

Poor toast-only examples:

Email is invalid.
Password is too short.
City is required.
Enter fullscreen mode Exit fullscreen mode

Field validation should remain beside the relevant field.


Simple toast state example

import {
  useState,
} from "react";

interface Toast {
  id: string;
  message: string;
  type:
    | "success"
    | "error";
}

export default function SaveForm() {
  const [
    toasts,
    setToasts,
  ] = useState<Toast[]>([]);

  function showToast(
    message: string,
    type:
      | "success"
      | "error",
  ) {
    const toast: Toast = {
      id:
        crypto.randomUUID(),
      message,
      type,
    };

    setToasts(
      (current) => [
        ...current,
        toast,
      ],
    );

    window.setTimeout(
      () => {
        setToasts(
          (current) =>
            current.filter(
              (item) =>
                item.id !==
                toast.id,
            ),
        );
      },
      4000,
    );
  }

  async function save() {
    try {
      await Promise.resolve();

      showToast(
        "Changes saved.",
        "success",
      );
    } catch {
      showToast(
        "Unable to save changes.",
        "error",
      );
    }
  }

  return (
    <>
      <button
        type="button"
        onClick={save}
      >
        Save
      </button>

      <div
        aria-live="polite"
        aria-atomic="false"
      >
        {toasts.map(
          (toast) => (
            <div
              key={toast.id}
              className={
                `toast ${toast.type}`
              }
            >
              {toast.message}
            </div>
          ),
        )}
      </div>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

In a real application, a tested toast library can handle timing, stacking, focus, dismissal, and animation.


18.7 Error Boundaries

Error boundaries handle unexpected rendering failures.

They do not replace normal form-error handling.

Use form state for expected failures:

Email already exists
Invalid coupon
Server rejected input
Enter fullscreen mode Exit fullscreen mode

Use an error boundary for unexpected failures:

Component crashed
Unexpected JavaScript exception
Rendering failure
Enter fullscreen mode Exit fullscreen mode

Complete class error boundary

import {
  Component,
} from "react";

import type {
  ErrorInfo,
  ReactNode,
} from "react";

interface ErrorBoundaryProps {
  children: ReactNode;
}

interface ErrorBoundaryState {
  hasError: boolean;
}

export default class ErrorBoundary extends Component<
  ErrorBoundaryProps,
  ErrorBoundaryState
> {
  state:
    ErrorBoundaryState = {
      hasError: false,
    };

  static getDerivedStateFromError():
    ErrorBoundaryState {
    return {
      hasError: true,
    };
  }

  componentDidCatch(
    error: Error,
    info: ErrorInfo,
  ) {
    console.error(
      "Unexpected form error:",
      error,
      info,
    );

    /*
     * Send the error to your
     * monitoring system here.
     */
  }

  render() {
    if (
      this.state.hasError
    ) {
      return (
        <section role="alert">
          <h1>
            Something went wrong
          </h1>

          <p>
            Reload the page and try
            again. Your server-saved
            draft is still available.
          </p>

          <button
            type="button"
            onClick={() => {
              window.location
                .reload();
            }}
          >
            Reload page
          </button>
        </section>
      );
    }

    return this.props.children;
  }
}
Enter fullscreen mode Exit fullscreen mode

18.8 Form Error Mapping

Create one tested utility for mapping backend fields.

import type {
  FieldPath,
  FieldValues,
  UseFormSetError,
} from "react-hook-form";

interface ApiValidationError {
  fieldErrors?: Record<
    string,
    string
  >;
}

interface MapOptions<
  TValues extends FieldValues,
> {
  error:
    ApiValidationError;

  allowedFields:
    readonly FieldPath<
      TValues
    >[];

  setError:
    UseFormSetError<
      TValues
    >;
}

export function mapApiErrors<
  TValues extends FieldValues,
>({
  error,
  allowedFields,
  setError,
}: MapOptions<TValues>) {
  if (!error.fieldErrors) {
    return false;
  }

  const allowed =
    new Set<string>(
      allowedFields,
    );

  let mapped = false;
  let focusAssigned =
    false;

  for (const [
    field,
    message,
  ] of Object.entries(
    error.fieldErrors,
  )) {
    if (!allowed.has(field)) {
      continue;
    }

    const fieldPath =
      field as FieldPath<
        TValues
      >;

    setError(
      fieldPath,
      {
        type: "server",
        message,
      },
      {
        shouldFocus:
          !focusAssigned,
      },
    );

    focusAssigned = true;
    mapped = true;
  }

  return mapped;
}
Enter fullscreen mode Exit fullscreen mode

This avoids reimplementing the mapping differently in every form.


18.9 Form Analytics

Useful analytics may include:

  • Form started
  • Step completed
  • Field error occurred
  • Draft restored
  • Upload failed
  • Form submitted
  • Form abandoned

Do not capture sensitive field values unnecessarily.

Bad:

analytics.track(
  "field_changed",
  {
    field: "password",
    value: password,
  },
);
Enter fullscreen mode Exit fullscreen mode

Better:

analytics.track(
  "validation_error",
  {
    form:
      "registration",
    field:
      "password",
    errorCode:
      "TOO_SHORT",
  },
);
Enter fullscreen mode Exit fullscreen mode

Never send passwords, complete document contents, or other sensitive form values into analytics systems.


18.10 Testing Strategy

A production form needs several testing layers.

Schema tests
→ Do validation rules work?

Component tests
→ Are errors displayed correctly?

Integration tests
→ Does submission map server errors?

Accessibility tests
→ Are labels and descriptions connected?

End-to-end tests
→ Can a real user complete the workflow?
Enter fullscreen mode Exit fullscreen mode

Schema test example

import {
  describe,
  expect,
  it,
} from "vitest";

import {
  registerSchema,
} from "./register.schema";

describe(
  "registerSchema",
  () => {
    it(
      "rejects an invalid email",
      () => {
        const result =
          registerSchema
            .safeParse({
              username:
                "karthik",
              email:
                "invalid",
              password:
                "Password123",
            });

        expect(
          result.success,
        ).toBe(false);
      },
    );

    it(
      "normalizes the email",
      () => {
        const result =
          registerSchema
            .parse({
              username:
                "karthik",
              email:
                "KARTHIK@EXAMPLE.COM",
              password:
                "Password123",
            });

        expect(
          result.email,
        ).toBe(
          "karthik@example.com",
        );
      },
    );
  },
);
Enter fullscreen mode Exit fullscreen mode

Component test example

import {
  render,
  screen,
} from "@testing-library/react";

import userEvent from "@testing-library/user-event";

import RegisterForm from "./RegisterForm";

it(
  "shows an email validation error",
  async () => {
    const user =
      userEvent.setup();

    render(
      <RegisterForm />,
    );

    await user.type(
      screen.getByLabelText(
        /email/i,
      ),
      "invalid",
    );

    await user.click(
      screen.getByRole(
        "button",
        {
          name:
            /create account/i,
        },
      ),
    );

    expect(
      await screen.findByText(
        /valid email/i,
      ),
    ).toBeInTheDocument();
  },
);
Enter fullscreen mode Exit fullscreen mode

Test what the user sees and does.

Avoid testing private implementation details.


Modern Form Architecture Diagram

Page or route
      ↓
Feature form component
      ↓
React Hook Form
      ↓
Validation schema
      ↓
API function
      ↓
Backend endpoint
      ↓
Service layer
      ↓
Database

Supporting systems:
├── Draft storage
├── File storage
├── Analytics
├── Logging
├── Error monitoring
└── Toast notifications
Enter fullscreen mode Exit fullscreen mode

Advantages of Modern Architecture

  • Clear responsibility boundaries
  • Reusable field components
  • Consistent accessibility
  • Testable schemas
  • Centralized API handling
  • Easier backend error mapping
  • Safer feature changes
  • Better team collaboration
  • Better monitoring and debugging

Disadvantages

  • More files
  • More architectural decisions
  • Can become overengineered for small forms
  • Abstractions require documentation
  • Generic components can become too complicated
  • Teams must maintain conventions
  • Shared layers may slow simple experimentation

Senior engineer tip

Do not create ten abstractions before building the first form.

A healthy evolution is:

Build one form
      ↓
Build the second form
      ↓
Identify real repetition
      ↓
Extract the repeated pattern
      ↓
Test the abstraction
Enter fullscreen mode Exit fullscreen mode

Abstract repeated problems, not imagined future problems.


Why this evolved

Production architecture made forms maintainable, but the platform itself has also continued to evolve. React now supports form actions, Next.js can invoke server-side functions from forms, browsers provide stronger autofill and device capabilities, and AI systems can extract or generate form data. These changes are moving forms toward hybrid human-and-agent workflows.


Stage 19: The Future of Forms

For many years, web-form development moved in one direction:

More JavaScript
More client state
More frontend abstractions
Enter fullscreen mode Exit fullscreen mode

Modern frameworks are now recovering useful capabilities from traditional HTML forms:

  • Native submission semantics
  • Progressive enhancement
  • Server-side mutation functions
  • Built-in pending states
  • Less manually written API plumbing

At the same time, AI is changing who—or what—fills the form.

The future is unlikely to eliminate forms completely.

Instead, forms will become:

  • More semantic
  • More server-connected
  • More adaptive
  • More multimodal
  • More automated
  • More privacy-sensitive

19.1 React Form Actions

React supports passing a function to a form's action prop.

<form action={saveProfile}>
Enter fullscreen mode Exit fullscreen mode

This moves submission closer to native form semantics.

React also provides tools such as:

  • useActionState
  • useFormStatus
  • useOptimistic

React 19 introduced form Actions and related hooks for managing action state, pending submission, and optimistic updates.


Basic React action example

async function saveMessage(
  formData: FormData,
) {
  const message =
    formData.get("message");

  console.log(message);
}

export default function MessageForm() {
  return (
    <form action={saveMessage}>
      <label htmlFor="message">
        Message
      </label>

      <textarea
        id="message"
        name="message"
        required
      />

      <button type="submit">
        Send
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

The form uses native name attributes to construct FormData.


useFormStatus

A child component can read the surrounding form submission state.

"use client";

import {
  useFormStatus,
} from "react-dom";

export function SubmitButton() {
  const {
    pending,
  } = useFormStatus();

  return (
    <button
      type="submit"
      disabled={pending}
    >
      {pending
        ? "Saving..."
        : "Save"}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

useFormStatus() provides information about the latest surrounding form submission, including whether it is pending.


useActionState

useActionState() connects a form action to returned state.

"use client";

import {
  useActionState,
} from "react";

interface FormState {
  success: boolean;
  message: string;
}

const initialState:
  FormState = {
    success: false,
    message: "",
  };

async function submitContact(
  previousState:
    FormState,

  formData:
    FormData,
): Promise<FormState> {
  const email =
    String(
      formData.get("email") ??
        "",
    );

  if (!email.includes("@")) {
    return {
      success: false,
      message:
        "Enter a valid email address.",
    };
  }

  return {
    success: true,
    message:
      "Message submitted.",
  };
}

export default function ContactForm() {
  const [
    state,
    formAction,
    isPending,
  ] = useActionState(
    submitContact,
    initialState,
  );

  return (
    <form action={formAction}>
      <label htmlFor="email">
        Email
      </label>

      <input
        id="email"
        name="email"
        type="email"
      />

      <button
        type="submit"
        disabled={isPending}
      >
        {isPending
          ? "Submitting..."
          : "Submit"}
      </button>

      {state.message && (
        <p
          role={
            state.success
              ? "status"
              : "alert"
          }
        >
          {state.message}
        </p>
      )}
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

useActionState() returns the current action state, a dispatchable action, and a pending indicator.


Does this replace React Hook Form?

Not always.

Actions solve:

  • Submission lifecycle
  • Server invocation
  • Pending state
  • Returned action state
  • Progressive enhancement

React Hook Form solves:

  • Field registration
  • Client validation
  • Dirty fields
  • Touched fields
  • Dynamic arrays
  • Conditional values
  • Complex field control
  • Client-side form state

A form can use:

Simple server form
→ React Action only

Large interactive form
→ React Hook Form

Large interactive server form
→ React Hook Form + Server Action
Enter fullscreen mode Exit fullscreen mode

The correct choice depends on interaction complexity.


19.2 Next.js App Router Forms

Next.js supports Server Actions—server-side functions that can be invoked by forms in Server or Client Components. The official Next.js forms guide describes them as Server Functions used to handle form submissions.


Complete Next.js Server Action example

Create:

app/register/actions.ts
Enter fullscreen mode Exit fullscreen mode
"use server";

import { z } from "zod";

const registerSchema =
  z.object({
    username:
      z
        .string()
        .trim()
        .min(
          3,
          "Username must contain at least 3 characters.",
        ),

    email:
      z
        .string()
        .trim()
        .email(
          "Enter a valid email address.",
        ),
  });

export interface RegisterState {
  success: boolean;
  message: string;

  fieldErrors: {
    username?: string[];
    email?: string[];
  };
}

export async function registerAction(
  previousState:
    RegisterState,

  formData:
    FormData,
): Promise<RegisterState> {
  const result =
    registerSchema.safeParse({
      username:
        formData.get(
          "username",
        ),

      email:
        formData.get("email"),
    });

  if (!result.success) {
    return {
      success: false,

      message:
        "Correct the invalid fields.",

      fieldErrors:
        result.error
          .flatten()
          .fieldErrors,
    };
  }

  /*
   * Authentication and authorization
   * must still be checked here where
   * appropriate.
   */

  const emailExists =
    result.data.email ===
    "existing@example.com";

  if (emailExists) {
    return {
      success: false,

      message:
        "This email is already registered.",

      fieldErrors: {
        email: [
          "This email is already registered.",
        ],
      },
    };
  }

  /*
   * Insert the user through the
   * service or repository layer.
   */

  return {
    success: true,

    message:
      "Registration successful.",

    fieldErrors: {},
  };
}
Enter fullscreen mode Exit fullscreen mode

Create:

app/register/RegisterForm.tsx
Enter fullscreen mode Exit fullscreen mode
"use client";

import {
  useActionState,
} from "react";

import {
  registerAction,
} from "./actions";

import type {
  RegisterState,
} from "./actions";

const initialState:
  RegisterState = {
    success: false,
    message: "",
    fieldErrors: {},
  };

export default function RegisterForm() {
  const [
    state,
    formAction,
    isPending,
  ] = useActionState(
    registerAction,
    initialState,
  );

  return (
    <form action={formAction}>
      <div className="field">
        <label htmlFor="username">
          Username
        </label>

        <input
          id="username"
          name="username"
          required
          aria-invalid={Boolean(
            state.fieldErrors
              .username,
          )}
          aria-describedby={
            state.fieldErrors
              .username
              ? "username-error"
              : undefined
          }
        />

        {state.fieldErrors
          .username?.map(
          (message) => (
            <p
              key={message}
              id="username-error"
              className="error"
            >
              {message}
            </p>
          ),
        )}
      </div>

      <div className="field">
        <label htmlFor="email">
          Email
        </label>

        <input
          id="email"
          name="email"
          type="email"
          required
          autoComplete="email"
          aria-invalid={Boolean(
            state.fieldErrors
              .email,
          )}
          aria-describedby={
            state.fieldErrors
              .email
              ? "email-error"
              : undefined
          }
        />

        {state.fieldErrors
          .email?.map(
          (message) => (
            <p
              key={message}
              id="email-error"
              className="error"
            >
              {message}
            </p>
          ),
        )}
      </div>

      {state.message && (
        <p
          role={
            state.success
              ? "status"
              : "alert"
          }
        >
          {state.message}
        </p>
      )}

      <button
        type="submit"
        disabled={isPending}
      >
        {isPending
          ? "Registering..."
          : "Register"}
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Next.js states that forms invoking Server Actions can support progressive-enhancement behavior, including submission before full client hydration in supported arrangements.


Server Actions are still public mutation boundaries

Do not think:

Server Action
= automatically secure
Enter fullscreen mode Exit fullscreen mode

A Server Action must still perform:

  • Authentication
  • Authorization
  • Runtime validation
  • Rate limiting where appropriate
  • Ownership checks
  • Safe database operations

Next.js security guidance treats Server Actions as externally reachable mutation boundaries and recommends validating authorization and data access inside the server-side operation.


19.3 Progressive Enhancement Returns

The earliest HTML forms worked without JavaScript.

Modern Server Actions partly return to that model:

Semantic HTML form
        ↓
Server-side operation
        ↓
Enhanced by React when available
Enter fullscreen mode Exit fullscreen mode

This is not a return to primitive websites.

It is a combination:

Native browser behavior
+
Modern server rendering
+
Selective client interaction
Enter fullscreen mode Exit fullscreen mode

Forms with function-based actions can support submission before the client bundle has fully loaded in compatible React Server Function setups.


19.4 Native Browser Improvements

Modern applications sometimes rebuild features browsers already understand.

Browsers provide:

  • Input types
  • Constraint validation
  • Date and time controls
  • Password-manager integration
  • Autofill
  • Address completion hints
  • File selection
  • Camera capture hints
  • Mobile keyboard hints
  • Credential-management integrations

The autocomplete attribute can describe information such as:

<input
  autocomplete="given-name"
/>

<input
  autocomplete="family-name"
/>

<input
  autocomplete="email"
/>

<input
  autocomplete="shipping street-address"
/>

<input
  autocomplete="shipping postal-code"
/>
Enter fullscreen mode Exit fullscreen mode

These tokens help browsers and password managers understand field meaning and offer appropriate saved values.


Future native form direction

Over the next decade, browsers will likely become better at:

  • Recognizing semantic field purpose
  • Prefilling structured identity information
  • Using passkeys and secure credentials
  • Validating common formats
  • Integrating camera and document capture
  • Preserving drafts
  • Supporting accessibility preferences

The strongest future forms will use browser semantics instead of fighting them.


19.5 AI-Assisted Form Completion

Traditional autofill matches known fields:

email
address
phone
name
Enter fullscreen mode Exit fullscreen mode

AI-assisted completion can infer values from broader context.

Example:

User uploads a resume
        ↓
AI extracts:
├── Name
├── Email
├── Skills
├── Education
└── Work experience
        ↓
Form is prefilled
        ↓
User reviews and confirms
Enter fullscreen mode Exit fullscreen mode

The correct pattern is:

AI suggests
Human reviews
Application validates
Server verifies
Enter fullscreen mode Exit fullscreen mode

Not:

AI guessed
        ↓
Automatically trusted
Enter fullscreen mode Exit fullscreen mode

Suggested-value interface

interface SuggestedFieldProps {
  label: string;
  currentValue: string;
  suggestedValue?: string;
  onAccept:
    (value: string) => void;
}

export default function SuggestedField({
  label,
  currentValue,
  suggestedValue,
  onAccept,
}: SuggestedFieldProps) {
  return (
    <section>
      <p>
        <strong>
          {label}
        </strong>
      </p>

      <p>
        Current:{" "}
        {currentValue ||
          "Not entered"}
      </p>

      {suggestedValue && (
        <div>
          <p>
            Suggested:{" "}
            {suggestedValue}
          </p>

          <button
            type="button"
            onClick={() => {
              onAccept(
                suggestedValue,
              );
            }}
          >
            Use suggestion
          </button>
        </div>
      )}
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

Use it with React Hook Form:

<SuggestedField
  label="Current role"
  currentValue={
    getValues(
      "currentRole",
    )
  }
  suggestedValue={
    resumeResult
      ?.currentRole
  }
  onAccept={(value) => {
    setValue(
      "currentRole",
      value,
      {
        shouldDirty: true,
        shouldValidate: true,
      },
    );
  }}
/>
Enter fullscreen mode Exit fullscreen mode

The user controls whether the suggestion is accepted.


19.6 AI-Generated Validation

AI could assist developers by proposing validation rules.

Example input:

Build a candidate registration form.
Username must be unique.
Password needs at least 12 characters.
Applicants must provide either LinkedIn or a portfolio.
Enter fullscreen mode Exit fullscreen mode

AI may generate:

const schema = z
  .object({
    username:
      z.string().min(3),

    password:
      z.string().min(12),

    linkedInUrl:
      z.string().url().optional(),

    portfolioUrl:
      z.string().url().optional(),
  })
  .refine(
    (values) =>
      values.linkedInUrl ||
      values.portfolioUrl,
    {
      message:
        "Provide LinkedIn or a portfolio.",
    },
  );
Enter fullscreen mode Exit fullscreen mode

But generated validation must be reviewed.

AI may:

  • Misunderstand the requirement
  • Create an incorrect regex
  • Forget localization
  • Reject valid international data
  • Encode biased assumptions
  • Generate only frontend checks
  • Miss authorization or database requirements

AI can accelerate implementation.

It cannot own the final business policy.


19.7 Voice-Based Form Filling

A user may say:

My name is Karthik. My email is karthik@example.com. I am applying for a frontend role.

The application can convert speech into text, extract fields, and ask for confirmation.

Voice
  ↓
Speech recognition
  ↓
Text transcript
  ↓
Field extraction
  ↓
User confirmation
  ↓
Validation
Enter fullscreen mode Exit fullscreen mode

The Web Speech API includes speech recognition and speech synthesis interfaces, although speech-recognition support remains inconsistent across major browsers and requires compatibility checks.


Simplified voice-input example

import {
  useState,
} from "react";

interface SpeechRecognitionEventLike {
  results: {
    [index: number]: {
      [index: number]: {
        transcript: string;
      };
    };
  };
}

export default function VoiceField() {
  const [
    value,
    setValue,
  ] = useState("");

  const [
    listening,
    setListening,
  ] = useState(false);

  function startListening() {
    const SpeechRecognition =
      (
        window as typeof window & {
          webkitSpeechRecognition?:
            new () => {
              lang: string;
              interimResults: boolean;
              start: () => void;
              onresult:
                (
                  event:
                    SpeechRecognitionEventLike,
                ) => void;
              onend:
                () => void;
              onerror:
                () => void;
            };
        }
      )
        .webkitSpeechRecognition;

    if (!SpeechRecognition) {
      alert(
        "Voice input is not supported in this browser.",
      );

      return;
    }

    const recognition =
      new SpeechRecognition();

    recognition.lang =
      "en-IN";

    recognition
      .interimResults = false;

    recognition.onresult =
      (event) => {
        const transcript =
          event.results[0][0]
            .transcript;

        setValue(transcript);
      };

    recognition.onend =
      () => {
        setListening(false);
      };

    recognition.onerror =
      () => {
        setListening(false);
      };

    setListening(true);
    recognition.start();
  }

  return (
    <div className="field">
      <label htmlFor="bio">
        Professional summary
      </label>

      <textarea
        id="bio"
        value={value}
        onChange={(event) => {
          setValue(
            event.target.value,
          );
        }}
      />

      <button
        type="button"
        onClick={startListening}
        disabled={listening}
      >
        {listening
          ? "Listening..."
          : "Fill using voice"}
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This example is intentionally simplified.

Production voice input needs:

  • Permission handling
  • Browser compatibility handling
  • Language selection
  • Transcript review
  • Error correction
  • Clear recording indication
  • Privacy disclosure
  • A non-voice alternative

19.8 OCR and Document Auto-Fill

OCR converts document images into machine-readable text.

A possible onboarding workflow:

Capture identity document
        ↓
Upload image
        ↓
OCR extracts text
        ↓
Parser identifies fields
        ↓
Form is prefilled
        ↓
User verifies every value
Enter fullscreen mode Exit fullscreen mode

Mobile file inputs may request camera capture through attributes such as accept and capture, although capture support is not consistent across every major browser.

Example:

<input
  type="file"
  accept="image/*"
  capture="environment"
/>
Enter fullscreen mode Exit fullscreen mode

OCR result-review example

interface ExtractedIdentity {
  fullName?: string;
  documentNumber?: string;
  dateOfBirth?: string;
}

interface ReviewProps {
  extracted:
    ExtractedIdentity;

  onAccept:
    (
      values:
        ExtractedIdentity,
    ) => void;
}

export default function OcrReview({
  extracted,
  onAccept,
}: ReviewProps) {
  return (
    <section>
      <h2>
        Review extracted
        information
      </h2>

      <dl>
        <dt>Full name</dt>
        <dd>
          {extracted.fullName ??
            "Not detected"}
        </dd>

        <dt>Document number</dt>
        <dd>
          {
            extracted
              .documentNumber ??
            "Not detected"
          }
        </dd>

        <dt>Date of birth</dt>
        <dd>
          {
            extracted
              .dateOfBirth ??
            "Not detected"
          }
        </dd>
      </dl>

      <p>
        Check every value against
        your document before
        continuing.
      </p>

      <button
        type="button"
        onClick={() => {
          onAccept(extracted);
        }}
      >
        Use these values
      </button>
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

OCR output must not be silently treated as correct.

Common OCR errors include:

  • 0 confused with O
  • 1 confused with I
  • Incorrect names
  • Wrong date formats
  • Missing spaces
  • Cropped text
  • Incorrect language recognition

19.9 Conversational Forms

Instead of displaying 30 inputs, a conversational interface may ask one question at a time.

Assistant:
What role are you applying for?

User:
Frontend developer.

Assistant:
How many years of experience do you have?

User:
Two.

Assistant:
Would you like to upload your resume?
Enter fullscreen mode Exit fullscreen mode

Internally, it may still build structured form data:

{
  role:
    "Frontend developer",

  yearsOfExperience:
    2,

  resume:
    File | null
}
Enter fullscreen mode Exit fullscreen mode

Benefits

  • Feels natural for some users
  • Can explain confusing questions
  • Can ask relevant follow-ups
  • Supports voice and text
  • Can hide irrelevant questions
  • Can help users who struggle with complex forms

Risks

  • Harder to review all information
  • Slower for expert users
  • Conversation history may hide errors
  • Accessibility must be carefully tested
  • Users may not know what will be asked
  • AI can misinterpret answers
  • Final structured confirmation is still required

Best hybrid pattern

Conversation collects data
        ↓
Structured review page
        ↓
User edits fields
        ↓
Explicit confirmation
        ↓
Final submission
Enter fullscreen mode Exit fullscreen mode

The conversation assists completion.

The structured form preserves clarity and control.


19.10 Agentic AI Completing Forms

An AI agent may eventually complete multi-site workflows for a user.

Example:

User:
Apply to suitable frontend jobs.

Agent:
1. Reads approved profile
2. Finds matching roles
3. Prepares application answers
4. Requests approval
5. Submits approved applications
6. Tracks responses
Enter fullscreen mode Exit fullscreen mode

This changes the form's consumer.

Historically:

Human
  ↓
Form
Enter fullscreen mode Exit fullscreen mode

Future:

Human
  ↓
Authorized agent
  ↓
Form or structured API
Enter fullscreen mode Exit fullscreen mode

Forms may therefore need to support:

  • Human users
  • Password managers
  • Browser autofill
  • Accessibility tools
  • Trusted automated agents
  • Structured machine-readable submission

Agent-safe form architecture

A future-ready system may expose:

Visual form
        +
Semantic HTML fields
        +
Documented validation schema
        +
Structured API
        +
Explicit authorization
Enter fullscreen mode Exit fullscreen mode

Agents should not need to guess meaning from visual position.

Field semantics should be clear:

<input
  name="email"
  type="email"
  autocomplete="email"
/>
Enter fullscreen mode Exit fullscreen mode

instead of:

<input
  name="field_17"
/>
Enter fullscreen mode Exit fullscreen mode

19.11 Privacy Implications

AI-enhanced forms may process:

  • Identity data
  • Employment history
  • Documents
  • Voice recordings
  • Location
  • Financial information
  • Personal preferences

Production systems should apply:

Data minimization
Purpose limitation
User consent
Secure transport
Access control
Retention limits
Encryption
Audit logging
Deletion workflows
Enter fullscreen mode Exit fullscreen mode

Ask:

  • Is this data necessary?
  • Where is it processed?
  • Is it sent to an external AI provider?
  • How long is it retained?
  • Can the user correct it?
  • Can the user delete it?
  • Is it used for model training?
  • Can staff access it?
  • Is the result explainable?

19.12 Security Implications

AI-generated or agent-submitted forms introduce new threats:

  • Fabricated data
  • Prompt injection in uploaded documents
  • Automated spam
  • Mass submissions
  • Identity misuse
  • Hidden malicious instructions
  • Unauthorized agent actions
  • Sensitive-data leakage

Security controls may include:

  • Authentication
  • Authorization
  • Rate limiting
  • File scanning
  • Schema validation
  • Database constraints
  • Agent scopes
  • Explicit user approval
  • Audit logs
  • Idempotency keys
  • Risk-based review

An AI agent should not receive unlimited permission merely because it is convenient.


19.13 Accessibility Implications

AI may improve accessibility through:

  • Voice entry
  • Plain-language explanations
  • Automatic translation
  • Error correction suggestions
  • Document extraction
  • Personalized input methods

It may also create new barriers:

  • Inaccurate voice recognition
  • Chat interfaces that hide form structure
  • Dynamic updates that are not announced
  • Suggestions that cannot be reached by keyboard
  • Unclear automated decisions
  • Interfaces requiring speech

The rule should be:

AI adds another path
It does not remove accessible
traditional paths
Enter fullscreen mode Exit fullscreen mode

Users should still be able to:

  • Type instead of speak
  • Upload instead of use a camera
  • Review extracted information
  • Correct AI suggestions
  • Complete the form without AI
  • Reach every action by keyboard

What Production Forms May Look Like in 5–10 Years

The following is a prediction, not a guaranteed roadmap.


Prediction 1: Fewer manually written API submission handlers

More frameworks will support:

<form action={serverFunction}>
Enter fullscreen mode Exit fullscreen mode

Developers will still validate and authorize on the server, but less code may be needed to connect a basic form to a mutation.


Prediction 2: Hybrid client-and-server validation

Forms will increasingly use:

Browser validation
        +
Client schema validation
        +
Server action validation
        +
Database constraints
Enter fullscreen mode Exit fullscreen mode

Each layer will have a clear responsibility.


Prediction 3: Forms generated from schemas

One schema may produce:

  • TypeScript types
  • Runtime validation
  • API documentation
  • Default values
  • Form fields
  • Error messages
  • Test cases

However, fully generated UI will still need human design review because the same data schema can support many different user experiences.


Prediction 4: Document-first onboarding

Instead of entering 30 fields manually:

Upload approved document
        ↓
Extract structured data
        ↓
Review highlighted uncertainty
        ↓
Confirm
Enter fullscreen mode Exit fullscreen mode

Users may edit only fields the system could not confidently extract.


Prediction 5: Confidence-aware AI suggestions

AI-generated values may include confidence information:

{
  field:
    "currentRole",

  value:
    "Frontend Developer",

  confidence:
    0.94,

  source:
    "resume-page-1"
}
Enter fullscreen mode Exit fullscreen mode

Low-confidence fields will require explicit review.


Prediction 6: Conversational and visual forms will coexist

Chat will not replace every form.

A likely interface:

Conversation
→ Helps collect and explain

Structured form
→ Supports review and correction

Summary screen
→ Supports explicit confirmation
Enter fullscreen mode Exit fullscreen mode

Prediction 7: Agents will prefer APIs over visual automation

Visual browser automation is fragile.

A more reliable future is:

Authorized agent
        ↓
Documented structured action
        ↓
Validated server operation
        ↓
Auditable result
Enter fullscreen mode Exit fullscreen mode

Applications may expose agent-readable capabilities while retaining human-facing forms.


Prediction 8: Stronger consent and auditability

Users may see:

Your assistant wants to:

✓ Read your approved profile
✓ Fill contact information
✓ Upload your selected resume
✗ Cannot submit without approval
Enter fullscreen mode Exit fullscreen mode

Actions may produce logs:

Agent filled form
User edited salary expectation
User approved submission
Application submitted at 14:32
Enter fullscreen mode Exit fullscreen mode

Prediction 9: Forms will become more adaptive

A form may adjust based on:

  • User role
  • Previous answers
  • Device
  • Accessibility preferences
  • Locale
  • Risk level
  • Available documents
  • Existing verified information

Adaptive does not mean unpredictable.

The form must still explain why information is requested.


Prediction 10: Semantic HTML will become more valuable

As browsers, password managers, assistive technology, and AI agents interact with forms, correct semantics will matter more—not less.

<label for="email">
  Email
</label>

<input
  id="email"
  name="email"
  type="email"
  autocomplete="email"
/>
Enter fullscreen mode Exit fullscreen mode

is useful to:

  • Humans
  • Browsers
  • Screen readers
  • Password managers
  • Testing tools
  • Automated agents

A Future Form Architecture

User or authorized agent
          ↓
Semantic adaptive interface
          ↓
Client-side schema validation
          ↓
Server Action or API
          ↓
Authentication and authorization
          ↓
Business-rule validation
          ↓
Database transaction
          ↓
Audit log
          ↓
Accessible confirmation
Enter fullscreen mode Exit fullscreen mode

Optional assistance:

Voice
OCR
AI suggestions
Conversational guidance
Document extraction
Autosave
Offline draft
Enter fullscreen mode Exit fullscreen mode

None of these optional features should bypass the trusted server pipeline.


Final Best-Practices Checklist

HTML Foundation

  • Use a real <form>.
  • Use native inputs where possible.
  • Give every field a meaningful name.
  • Connect labels using htmlFor and id.
  • Use correct input types.
  • Add useful autocomplete tokens.
  • Use explicit button types.

Validation

  • Validate on the frontend for user experience.
  • Validate again on the server.
  • Use database constraints for integrity.
  • Separate structural validation from business rules.
  • Return consistent error codes and field paths.
  • Never trust a TypeScript assertion as runtime validation.

React Hook Form

  • Provide complete default values.
  • Prefer register() for native inputs.
  • Use Controller only when controlled integration is required.
  • Use useFieldArray() for dynamic arrays.
  • Watch only the values required for rendering.
  • Map backend field errors through setError().
  • Use root errors for form-wide failures.

Accessibility

  • Keep visible labels.
  • Associate hints and errors.
  • Use aria-invalid after validation.
  • Preserve visible keyboard focus.
  • Group related choices using fieldset and legend.
  • Focus the first invalid field or error summary after submission.
  • Do not rely only on color.
  • Do not make AI or voice the only completion path.

Performance

  • Keep rapidly changing state close to where it is used.
  • Avoid watching the complete form unnecessarily.
  • Choose validation timing deliberately.
  • Debounce asynchronous checks.
  • Cancel stale requests.
  • Profile before adding memoization.
  • Measure user-visible delays rather than counting renders alone.

Security

  • Treat all client input as untrusted.
  • Validate uploaded files on the server.
  • Do not store sensitive drafts carelessly.
  • Protect mutations with authentication and authorization.
  • Rate-limit abuse-sensitive operations.
  • Avoid exposing internal errors.
  • Audit automated or agent-driven submissions.
  • Require explicit approval for high-impact actions.

Final Series Summary

Web forms began as a built-in browser feature.

<form
  action="/register"
  method="POST"
>
Enter fullscreen mode Exit fullscreen mode

The browser collected values, sent a request, and loaded a new page.

Then validation moved into HTML:

<input
  type="email"
  required
  minlength="3"
/>
Enter fullscreen mode Exit fullscreen mode

JavaScript added custom behavior:

event.preventDefault();
Enter fullscreen mode Exit fullscreen mode

AJAX removed full-page refreshes:

await fetch(
  "/api/register",
);
Enter fullscreen mode Exit fullscreen mode

React made UI state declarative:

<input
  value={email}
  onChange={handleChange}
/>
Enter fullscreen mode Exit fullscreen mode

Large controlled forms created boilerplate:

values
errors
touched
dirty
loading
validation
Enter fullscreen mode Exit fullscreen mode

Form libraries organized that complexity.

React Hook Form reduced value-driven parent updates:

<input
  {...register("email")}
/>
Enter fullscreen mode Exit fullscreen mode

Zod centralized runtime validation:

const schema =
  z.object({
    email:
      z.string().email(),
  });
Enter fullscreen mode Exit fullscreen mode

The resolver connected the schema to the form:

useForm({
  resolver:
    zodResolver(schema),
});
Enter fullscreen mode Exit fullscreen mode

The backend protected the trusted boundary:

schema.safeParse(
  request.body,
);
Enter fullscreen mode Exit fullscreen mode

Server errors returned to matching fields:

setError(
  "email",
  {
    type: "server",
    message:
      "Email already exists.",
  },
);
Enter fullscreen mode Exit fullscreen mode

Enterprise forms introduced workflows:

Steps
Dynamic arrays
Uploads
Autosave
Drafts
Optimistic UI
Enter fullscreen mode Exit fullscreen mode

Modern React and Next.js are reconnecting forms with server-side actions:

<form action={serverAction}>
Enter fullscreen mode Exit fullscreen mode

And future forms will increasingly accept information through:

Typing
Autofill
Documents
Voice
Conversation
Authorized AI agents
Enter fullscreen mode Exit fullscreen mode

But the fundamental engineering principles will remain the same:

Use semantic HTML.

Make errors understandable.

Treat client data as untrusted.

Keep users in control.

Validate at every trust boundary.

Design accessibility from the beginning.

Use abstractions only when they
remove real complexity.
Enter fullscreen mode Exit fullscreen mode

A modern form is not simply a collection of inputs.

It is a carefully designed communication system between:

The user
The browser
The frontend
The backend
The database
Assistive technology
And increasingly, AI agents
Enter fullscreen mode Exit fullscreen mode

Understanding this evolution means you no longer need to memorize React Hook Form as a mysterious library.

You understand the problems it inherited, the trade-offs it makes, and the reason the web-form ecosystem evolved in this direction.

Top comments (0)