FullStack Course LogoFullStack Course
Module: React and Ecosystem
React and Ecosystem·111·17 MIN READ

111: Advanced Forms — React Hook Form, Zod, Server Errors, and Accessibility

TOPICS COVERED: Advanced Forms — React Hook Form, Zod, Server Errors, and Accessibility

Learning objectives

By the end of this lesson, you should be able to:

  • choose between controlled and uncontrolled form architecture;
  • use React Hook Form (RHF) when a form has substantial state;
  • validate parsed values with Zod;
  • connect Zod to RHF through zodResolver;
  • model nested fields and dynamic field arrays;
  • map validation failures returned by a server back to individual fields;
  • preserve the user's values when a request fails;
  • manage focus and accessible relationships between controls and error messages;
  • coordinate form behavior with React Actions or TanStack Query mutations.

When local useState is enough

For a form with only two fields, local state is often the clearest solution:

jsx
const [title, setTitle] =
  useState('');

There is no engineering benefit in adding a form library merely because the form happens to be a form. Keep the architecture proportional to the problem.

A form library starts earning its place when the form has several of these characteristics:

  • many fields;
  • nested structures;
  • field arrays;
  • complex validation;
  • touched and dirty semantics;
  • repeated mapping of server errors;
  • performance pressure caused by controlled rerenders.

The useful distinction is not “library versus no library.” It is whether the library removes enough coordination work to justify its own API and state model.

Controlled inputs are still a valid choice when another part of the component must synchronously own and display every value. Uncontrolled inputs, which RHF uses effectively for native controls, avoid making every keystroke a reason for the parent component to rerender. Choose the ownership model deliberately instead of treating either model as universally superior.

Install

bash
npm install react-hook-form zod @hookform/resolvers

Schema

Start with the shape of the data the form is meant to produce. This gives the client a precise validation boundary:

jsx
import {
  z,
} from 'zod';

export const taskSchema =
  z.object({
    title:
      z.string()
        .trim()
        .min(
          3,
          'Use at least 3 characters.',
        )
        .max(
          80,
          'Use 80 characters or fewer.',
        ),

    priority:
      z.enum([
        'low',
        'normal',
        'high',
      ]),

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

Zod does more than check a few conditions. It parses unknown input and returns, or reports failure for, the shape described by the schema. That parsed result is useful at the boundary where form strings become application data.

This validation is still a client-side convenience and correctness check. Do not confuse it with server authorization. A browser can skip or replace every client-side check, so the server must enforce permissions and business rules independently.

React Hook Form setup

The basic RHF setup registers native controls, delegates validation to the resolver, and exposes the state needed to render feedback:

jsx
import {
  useForm,
} from 'react-hook-form';

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

function TaskForm({
  onSubmitTask,
}) {
  const {
    register,
    handleSubmit,
    setError,
    reset,
    formState: {
      errors,
      isSubmitting,
      isDirty,
    },
  } =
    useForm({
      resolver:
        zodResolver(
          taskSchema,
        ),

      defaultValues: {
        title: '',
        priority:
          'normal',
        dueDate: '',
      },
    });

  async function submit(
    values,
  ) {
    await onSubmitTask(
      values,
    );
  }

  return (
    <form
      onSubmit={
        handleSubmit(
          submit,
        )
      }
      noValidate
    >
      ...
    </form>
  );
}

defaultValues establishes a stable initial shape, which is particularly helpful when fields are nested or appear conditionally. handleSubmit runs the resolver before calling submit, so the submit function receives values that passed client validation.

The resolver does not remove the need to understand the values arriving from the browser. A date input, number input, checkbox, and custom widget can all produce different representations. Confirm the shape at the boundary and map it explicitly when the API contract differs from the form representation.

noValidate is appropriate in this example because the exercise intentionally renders custom errors. Native browser validation is still a good choice when its behavior and messages match the desired user experience; turning it off is not automatically an accessibility improvement.

Accessible field error

An error should be available both visually and through the accessibility tree. The input needs an explicit relationship to the message that describes its current problem:

jsx
function TitleField({
  register,
  error,
}) {
  const errorId =
    'title-error';

  return (
    <div>
      <label htmlFor="title">
        Title
      </label>

      <input
        id="title"
        {
          ...register(
            'title',
          )
        }
        aria-invalid={
          Boolean(error)
        }
        aria-describedby={
          error
            ? errorId
            : undefined
        }
      />

      {error && (
        <p
          id={errorId}
          role="alert"
        >
          {
            error.message
          }
        </p>
      )}
    </div>
  );
}

Color alone cannot communicate an error reliably. Some users cannot distinguish the chosen colors, and color does not establish which message belongs to which control. The label identifies the control, while aria-invalid and aria-describedby expose its invalid state and associated explanation programmatically.

Complete form

The following version combines local schema errors, server field errors, a form-level error, value preservation, and focus management:

jsx
function TaskForm({
  createTask,
}) {
  const {
    register,
    handleSubmit,
    setError,
    reset,
    setFocus,
    formState: {
      errors,
      isSubmitting,
    },
  } =
    useForm({
      resolver:
        zodResolver(
          taskSchema,
        ),

      defaultValues: {
        title: '',
        priority:
          'normal',
        dueDate: '',
      },
    });

  async function submit(
    values,
  ) {
    try {
      const task =
        await createTask(
          values,
        );

      reset();

      return task;
    } catch (error) {
      if (
        error.status === 422
        && error.body?.errors
      ) {
        const entries =
          Object.entries(
            error.body
              .errors,
          );

        for (
          const [
            field,
            message,
          ] of entries
        ) {
          setError(
            field,
            {
              type:
                'server',
              message,
            },
          );
        }

        const firstField =
          entries[0]?.[0];

        if (firstField) {
          setFocus(
            firstField,
          );
        }

        return;
      }

      setError(
        'root.server',
        {
          type: 'server',
          message:
            'Could not save. Try again.',
        },
      );
    }
  }

  return (
    <form
      onSubmit={
        handleSubmit(
          submit,
        )
      }
      noValidate
    >
      <label htmlFor="title">
        Title
      </label>

      <input
        id="title"
        {
          ...register(
            'title',
          )
        }
        aria-invalid={
          Boolean(
            errors.title,
          )
        }
        aria-describedby={
          errors.title
            ? 'title-error'
            : undefined
        }
      />

      {errors.title && (
        <p
          id="title-error"
          role="alert"
        >
          {
            errors
              .title
              .message
          }
        </p>
      )}

      <label
        htmlFor="priority"
      >
        Priority
      </label>

      <select
        id="priority"
        {
          ...register(
            'priority',
          )
        }
      >
        <option value="low">
          Low
        </option>
        <option value="normal">
          Normal
        </option>
        <option value="high">
          High
        </option>
      </select>

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

      <button
        disabled={
          isSubmitting
        }
      >
        {isSubmitting
          ? 'Saving…'
          : 'Save'}
      </button>
    </form>
  );
}

There are two separate success and failure decisions here. A successful create resets the form because there is no longer a draft to preserve. A failed request does not reset it, so the user can correct the problem without reconstructing the form. A 422 with field entries is rendered beside those fields and moves focus to the first one. An error without a field mapping is stored under root.server, where it can be rendered as a form-level message.

In production code, also make the error contract defensive. Network failures may not have the same status and body properties as an HTTP response, and a malformed error payload should not cause the error-rendering path itself to fail. The example focuses on the mapping mechanics; the boundary that normalizes API errors should be tested separately.

Field arrays

Repeated inputs are a common point where hand-written state becomes awkward. RHF's useFieldArray owns the array operations and supplies an identity for each rendered item:

jsx
const {
  fields,
  append,
  remove,
} =
  useFieldArray({
    control,
    name: 'checklist',
  });

Render each row with the field-generated stable ID as its React key:

jsx
{fields.map(
  (field, index) => (
    <div key={field.id}>
      <input
        {
          ...register(
            `checklist.${index}.title`,
          )
        }
      />

      <button
        type="button"
        onClick={() =>
          remove(index)
        }
      >
        Remove
      </button>
    </div>
  ),
)}

The index still identifies the current path used for registration, but it is not the identity of the row. Do not use an array index as the React key for a dynamic field array when the library provides a stable field ID. Otherwise, removing or moving an item can make React associate an input's DOM state with the wrong data.

Server validation contract

A predictable response contract makes client-side error mapping straightforward. For example:

json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "fields": {
      "title": "Already exists"
    }
  }
}

The server may reject a value that passed the client schema. That is expected, not evidence that one validator is broken. The server has information the browser may not have, including:

  • database uniqueness;
  • authorization;
  • stale business rules;
  • concurrent changes.

Client validation improves feedback and avoids obviously bad requests. The server remains authoritative for what may be stored or performed.

This separation also makes debugging more direct. If the client rejects the value, inspect the form value and schema. If the client accepts it but the server rejects it, inspect the request payload, authenticated user, current database state, and server-side rule. Those are different failures even when both appear as “validation failed” in the interface.

TanStack Query mutation integration

TanStack Query can own the mutation lifecycle and invalidate related server state after a successful write:

jsx
const mutation =
  useMutation({
    mutationFn:
      createTask,

    onSuccess:
      async () => {
        await queryClient
          .invalidateQueries({
            queryKey:
              ['tasks'],
          });
      },
  });

Then submit the parsed form values through the mutation:

jsx
await mutation
  .mutateAsync(values);

Use mutateAsync when the form workflow needs Promise control, such as waiting for completion before resetting or navigating. The v5 object syntax for useMutation remains the appropriate syntax here.

React Action integration

React Actions can own pending and form-state semantics, while RHF can still manage complex client-side fields, nested values, and field-level interaction.

That does not mean both tools belong in every form. Decide which system owns submission, pending state, errors, and the resulting server refresh. Combining tools without assigning those responsibilities creates duplicate state and confusing failure paths.

Dirty state

When a user tries to leave a dirty form, the product needs an intentional policy. Possible behaviors include:

  • allow leaving;
  • warn;
  • autosave;
  • persist a draft.

The browser confirmation is not automatically the right answer. For a tiny form, interrupting every navigation may be worse than losing a negligible draft. For a long workflow, the product may need durable draft persistence instead.

Reset behavior

After a successful create, an empty form is usually the next useful state:

jsx
reset();

After a successful edit, reset to the representation of the saved entity:

jsx
reset(savedTask);

Do not reset as soon as submission starts. If the request fails, the user should still have the values they entered and be able to act on the returned error.

Common mistakes

These failures usually come from mixing responsibilities or treating a form as only a collection of inputs:

  • treating client validation as security;
  • losing values when a 422 response arrives;
  • putting every field problem into one root error;
  • failing to connect errors with aria-describedby;
  • leaving submit disabled forever after one error;
  • using index keys in dynamic arrays;
  • controlling every field when it is not necessary;
  • duplicating server errors across several state systems.

Exercises

  1. Build a task form with RHF and Zod.
  2. Map a simulated 422 response to field errors.
  3. Add a dynamic checklist with useFieldArray.
  4. Focus the first field rejected by the server.
  5. Integrate a TanStack Query v5 mutation.
  6. Test the form with only a keyboard and inspect its screen-reader accessibility tree.

Exit questions

  1. When is plain useState enough for a form?
  2. What does Zod provide at the input boundary?
  3. Why is server validation still authoritative?
  4. How should a field error be associated with its input?
  5. When should a form reset, and when should it preserve its draft?
  6. How can RHF and TanStack Query divide responsibilities without duplicating state?

Official references


Deep dive: forms are state machines even when the library hides the mechanics

A realistic form is not just “loading” or “not loading.” It moves through states that affect what the user can do and what the UI should announce:

text
pristine
dirty
validating
invalid
submitting
server-invalid
success
unexpected-error

Reducing all of those states to one boolean loses useful distinctions. For example, a form can be dirty but valid, invalid before submission, or waiting for a server response:

text
isLoading boolean

React Hook Form exposes useful state fields, but it cannot choose the product behavior for every state. You still need to define when errors appear, whether values remain editable, where focus goes, and what happens after success or failure.

Default values

Give the form a stable initial shape rather than allowing fields to alternate between undefined and defined values:

jsx
useForm({
  defaultValues: {
    title: '',
    priority: 'normal',
    assigneeId: '',
    tags: [],
  },
});

Async edit forms require a separate identity decision. One possible pattern is to wait for the query and give the form a key based on the task identity:

jsx
const taskQuery = useQuery(...);

if (taskQuery.isPending) return <Skeleton />;

return (
  <TaskForm
    key={taskQuery.data.task.id}
    defaultValues={mapTaskToForm(taskQuery.data.task)}
  />
);

That key intentionally creates a fresh form when the identity changes. It is not an invitation to recreate the form on every data update. In particular, do not continuously call reset(serverData) on every background refetch while the user has unsaved edits. A refetch can arrive while the user is typing and overwrite the local draft.

Zod preprocessing/coercion

HTML numeric controls submit strings, even when the UI presents a number input. A schema can coerce that string:

jsx
const schema = z.object({
  estimateHours: z.coerce
    .number()
    .min(0)
    .max(1000),
});

Coercion is convenient, but do not treat it as magic. Empty strings, whitespace, and NaN are edge cases worth testing with the actual values your form produces:

text
empty string
whitespace
NaN

When an empty value should mean “not supplied,” explicit preprocessing can communicate that policy more clearly:

jsx
const optionalNumber = z.preprocess(
  (value) => {
    if (value === '') return undefined;
    return Number(value);
  },
  z.number().finite().optional(),
);

The schema should describe the application value, but the mapping from browser strings to that value still deserves deliberate tests.

Cross-field validation

Some rules depend on more than one field. For a date range, attaching the issue to endDate gives the UI a useful place to render the message:

jsx
const scheduleSchema = z
  .object({
    startDate: z.string(),
    endDate: z.string(),
  })
  .superRefine((value, context) => {
    const start = new Date(value.startDate);
    const end = new Date(value.endDate);

    if (end < start) {
      context.addIssue({
        code: z.ZodIssueCode.custom,
        path: ['endDate'],
        message: 'End date must be after start date.',
      });
    }
  });

This client rule gives the user immediate feedback. The server must repeat the rule using its authoritative timezone and business policy, because the two environments may not interpret dates identically and the client cannot enforce integrity.

Controller for non-native controlled components

Native inputs generally work well with register. A custom select or date picker that owns its own value and change API may need Controller to connect that API to RHF:

jsx
<Controller
  name="assigneeId"
  control={control}
  render={({ field, fieldState }) => (
    <UserSelect
      value={field.value}
      onValueChange={field.onChange}
      error={fieldState.error?.message}
    />
  )}
/>

The controller adapts the widget's value and onValueChange to RHF's field contract. Do not use Controller for every plain input unnecessarily; register is simpler when the native control already exposes the expected behavior.

Field arrays and identity

For an array that can be appended, removed, or reordered, request the operations you actually need:

jsx
const { fields, append, remove, move } = useFieldArray({
  control,
  name: 'checklist',
});

The React key should be:

jsx
key={field.id}

not the index. The index is a position and changes when rows move. The generated ID represents the rendered row's identity. If the server also gives an item an ID, keep the library identity and the domain identity conceptually separate. Do not accidentally overwrite RHF's generated identity property.

Nested server errors

Nested paths need to use the same field naming convention as the form. For example, the server might return:

json
{
  "error": {
    "fields": {
      "checklist.2.title": "Required"
    }
  }
}

The mapping itself can remain small:

jsx
for (const [path, message] of Object.entries(serverErrors)) {
  setError(path, {
    type: 'server',
    message,
  });
}

Before blindly accepting arbitrary server keys, validate that the paths correspond to fields your form allows. This matters if the mapping or rendering helper could be influenced in a way that exposes unexpected data or targets unintended controls.

Root errors

Use a form or root error for a failure that cannot be attributed to one field:

jsx
setError('root.server', {
  type: 'server',
  message: 'Could not save changes.',
});

Typical examples include:

  • network failure;
  • a 409 record conflict;
  • an unavailable server.

A 409 can require richer conflict UI than one generic message. The status tells you that the current write conflicts with server state; it does not tell you that displaying a sentence is the only useful response.

Focus and error summary

On a long form, a summary can give the user a single place to understand how many problems remain:

jsx
<section role="alert" tabIndex={-1} ref={summaryRef}>
  <h2>Check 3 fields</h2>
  <ul>...</ul>
</section>

After a failed submission, focus either the summary or the first invalid control, based on a tested UX decision. Links in an error summary can move focus directly to the corresponding fields. Do not move focus repeatedly while the user is correcting input; repeated announcements and focus jumps make the form difficult to operate.

Dirty field conflict handling

Consider this edit workflow:

  1. the task loads at version 4;
  2. the user modifies the title;
  3. someone else changes the server task to version 5;
  4. the save returns 409.

Possible responses include:

  • show the latest server values beside the local draft;
  • allow an overwrite with explicit confirmation;
  • merge non-conflicting fields;
  • reload and discard local changes.

The form library can preserve values and report the failure, but it cannot decide this business policy. That decision belongs to the application and its product requirements.

shouldUnregister

When dynamic fields are unmounted, RHF can either keep them registered or unregister them, depending on configuration. That choice affects:

  • hidden conditional values;
  • wizard steps;
  • validation;
  • the submitted payload.

Decide explicitly whether a hidden field should still be submitted. Otherwise, a field can appear to have disappeared from the UI while continuing to affect validation or the request body.

Multi-step forms

There are several reasonable owners for state across a multi-step workflow:

  • one RHF instance across all steps;
  • a route or URL for each step;
  • a client store for the draft;
  • a server draft resource.

For a long business workflow, server-side draft persistence may be safer than keeping everything in memory. A browser refresh should not necessarily destroy an hour of work, so persistence should be selected according to the cost of losing the draft.

File uploads

RHF can register a file input, but registration is only the form-state part of the problem. An actual upload design may require:

  • multipart/FormData or a signed upload workflow;
  • size and type validation;
  • progress;
  • cancellation;
  • retry;
  • server scanning and policy enforcement.

Do not serialize a File into JSON. Choose an upload transport that can represent the file and define what happens when transfer, scanning, or subsequent form submission fails.

The form may therefore have more than one lifecycle: a file can be selected locally, uploaded to storage, scanned by the server, and only then associated with the task. Keep those states distinct from the ordinary text-field validation state so a failed upload does not look like a successful task save.

TanStack Query v5 integration details

One possible arrangement lets the submit function await the mutation, reset to the saved representation, and then refresh the task list:

jsx
const mutation = useMutation({
  mutationFn: createTask,
});

async function submit(values) {
  try {
    const task = await mutation.mutateAsync(values);

    reset(mapTaskToForm(task));

    await queryClient.invalidateQueries({
      queryKey: ['tasks'],
    });
  } catch (error) {
    mapApiErrors(error, setError);
  }
}

Be careful not to invalidate twice accidentally. If onSuccess already performs invalidation, duplicating it in both the hook options and submit adds work without changing the result unless that duplication is intentional. Keep mutation responsibility in one place so pending, error, reset, and refresh behavior remain understandable.

React Actions versus RHF

For a simple form, this may be enough:

text
React Action + native form

A complex dynamic form may justify this combination:

text
RHF + Zod + mutation/Action

The decision should follow the complexity the tool removes, not the number of tools introduced:

text
What complexity does this tool remove?
What duplicate state would it create?

Do not combine tools simply because the course has introduced them. Every additional owner needs a clear boundary.

Accessibility beyond labels

An accessibility review should cover more than whether each input has a label:

  • fieldset and legend for grouped radios and checkboxes;
  • required status conveyed programmatically;
  • appropriate autocomplete tokens;
  • inputmode where it improves mobile input;
  • the relationship between a control and its error;
  • disabled versus readonly semantics;
  • status messages;
  • focus behavior;
  • keyboard order.

Do not wrap every error in role="alert" when validation updates on every keystroke. Constant announcements can become noisy and make it harder for assistive-technology users to understand the current state. Choose announcement behavior based on when and how errors appear.

Testing forms

Test behavior at the boundaries where users and the server interact with the form:

text
valid submit
required error
server 422
server 409
server 500
double submit
keyboard
reset after success
preserve after failure
dynamic field add/remove

Use user-event rather than assigning DOM values directly. User-level interaction exercises the event and focus behavior that the real form depends on, while direct assignment can bypass the wiring you are trying to verify.

Failure clinic

Schema mismatch with API

The client accepts a string, while the server expects null or an omitted property. Define an explicit mapping layer instead of assuming the schema's output is already the API payload.

Reset after submit starts

The user loses the draft when the request fails. Reset only after the successful response, or reset to the saved representation when editing.

Controlled custom widget not wired through Controller

The widget renders, but its value never reaches form state. Adapt its value and change callbacks through Controller.

Background query reset overwrites dirty edits

This is an ownership bug. Background server data was allowed to replace a local draft without checking whether the user had unsaved changes.

Deep-dive exercises

  1. Build an async edit form with an intentional reset-on-ID strategy.
  2. Add Controller around a custom select.
  3. Add cross-field schedule validation.
  4. Map nested 422 errors.
  5. Add a 409 conflict UI.
  6. Build a multi-step form and decide who owns persistence.
  7. Test file-field semantics without sending JSON.
  8. Conduct an accessibility audit.

Mastery check

Explain each of these in terms of ownership, user behavior, and failure handling:

  • the RHF ownership model;
  • register versus Controller;
  • the default/reset strategy;
  • field-array identity;
  • server-error mapping;
  • dirty conflict handling;
  • why form tooling never replaces server validation.

Production case study: edit form versus background server refresh

Suppose a task query returns:

json
{
  "id": "t1",
  "title": "Prepare invoice",
  "priority": "normal",
  "version": 3
}

The user opens the editor and changes the title locally. While the user is typing, Query refetches and returns version 4 because another user changed the priority.

Do not blindly reset the form whenever query data changes:

jsx
useEffect(() => {
  reset(taskQuery.data);
}, [taskQuery.data, reset]);

That effect can overwrite the title draft with the newly fetched object. The query's data is current server state, but it is not automatically the user's current form state.

A safer strategy keeps those sources distinct:

text
form initialized from version 3
draft owns edits
background server version 4 noted separately
save includes base version 3
server returns 409
UI offers reload/merge

This is the behavior required by real collaborative editing. For a simple single-user form, it may be reasonable to disable background refetch while editing or reset only when the entity ID changes. The right choice depends on the cost of a conflict and the expected concurrency.

Form architecture therefore depends on concurrency requirements, not only on the validation library's APIs.


Additional depth: watch, useWatch, and avoiding form-wide rerenders

React Hook Form can expose field values to a component, but the breadth of that subscription affects how much work the component may do. A broad subscription observes the whole form:

jsx
const values = watch();

That can cause more component work because the component observes every field. A narrow subscription observes only the value needed by one UI region:

jsx
const priority = useWatch({
  control,
  name: 'priority',
});

For example, help text that appears only for high-priority tasks can subscribe to priority without making the entire form depend on every change:

jsx
function PriorityHelp({ control }) {
  const priority = useWatch({
    control,
    name: 'priority',
  });

  if (priority !== 'high') {
    return null;
  }

  return (
    <p>
      High-priority tasks notify the assigned team immediately.
    </p>
  );
}

Use narrow subscriptions for large forms when only a small region needs to react to a particular field. This is a performance and ownership decision, not a requirement to optimize every small form prematurely.

When investigating a slow form, look for broad subscriptions, unnecessary controlled wrappers, and parent components that rerender large subtrees on each keystroke. Measure the behavior before changing the architecture; the goal is to reduce avoidable work while keeping the form's state ownership understandable.

Programmatic updates

When a product interaction changes a field outside the normal input event, use the form API and state the side effects you want:

jsx
setValue('priority', 'high', {
  shouldDirty: true,
  shouldValidate: true,
});

Here the update marks the field dirty and asks RHF to validate it. Do not mirror every field into separate React state just to observe changes; that recreates the synchronization problem the form library is intended to manage.

getValues

getValues() reads the current form data without subscribing the component to future updates. That makes it appropriate for one-off event logic:

jsx
function preview() {
  const values = getValues();
  openPreview(values);
}

Choose the API according to the behavior you need:

text
reactive render subscription
or one-time event read

The distinction is the same ownership principle that appears throughout React: subscribe when rendering depends on changing data, and read imperatively when an event needs a snapshot.

That distinction is also useful during review. If a component reads a value only when the user clicks Preview, getValues() avoids making its render path depend on the entire form. If the component must change its markup whenever a value changes, use a subscription so React can render from current state rather than relying on an imperative read.

Neither API changes the server contract or replaces submission validation. The final submit still needs the resolver and the server response still needs to be handled. These APIs only determine how a component observes the draft while the user is working.

When the observed value looks wrong, inspect the registered field name and the event reaching RHF before adding another state store. A stale or missing value is often a wiring problem, not proof that the form needs more global state.

Reader page: /react/lesson/111/advanced-forms-react-hook-form-zod-server-errors-and-accessibility