DEV_NET_CORE
GET_STARTED
ReactRouting, forms, and server communication

Forms, validation, optimistic updates, and mutation states

Overview

Forms, validation, optimistic updates, and mutation states are the practical core of user-driven React applications. Reading data is only half of the job; users also create accounts, edit records, submit payments, toggle settings, upload files, and delete data. A strong React developer needs to model those interactions clearly from the user's input to the server mutation and back to the updated UI.

Modern React stacks usually handle mutations with one of these patterns:

  • Controlled forms and manual submit handlers.
  • Route actions and route-aware forms.
  • Fetchers for in-place mutations without navigation.
  • Client cache libraries such as TanStack Query.
  • Framework server actions or route handlers.

The same principles apply across tools:

  • Validate input close to the boundary that receives it.
  • Show pending state while work is in progress.
  • Prevent accidental duplicate submissions.
  • Return field-level errors for expected validation failures.
  • Use optimistic UI only when rollback or reconciliation is understood.
  • Revalidate or invalidate data after successful mutations.
  • Keep server state and local UI state separate.

For interviews, this topic matters because mutation code often reveals whether a developer understands real product behavior: latency, validation, race conditions, accessibility, consistency, optimistic failure handling, and user trust.

Core Concepts

Forms as User Intent

A form captures a user intent: create, update, search, authenticate, upload, or delete. React can manage form fields with controlled inputs, but the browser's form model is still valuable because it provides semantics, keyboard behavior, accessibility, and a natural submit event.

Code
function LoginForm() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    await login({ email, password });
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Email
        <input
          type="email"
          value={email}
          onChange={(event) => setEmail(event.currentTarget.value)}
        />
      </label>
      <label>
        Password
        <input
          type="password"
          value={password}
          onChange={(event) => setPassword(event.currentTarget.value)}
        />
      </label>
      <button type="submit">Log in</button>
    </form>
  );
}

Prefer onSubmit on the form rather than only onClick on the button. It supports Enter-key submission and keeps the form behavior accessible.

Controlled and Uncontrolled Form State

Controlled inputs store the current field value in React state.

Code
const [title, setTitle] = useState("");

<input
  name="title"
  value={title}
  onChange={(event) => setTitle(event.currentTarget.value)}
/>;

This is useful when the UI needs live validation, formatting, conditional rendering, reset behavior, or coordination with other state.

Uncontrolled inputs let the DOM own the current value. You can read values on submit:

Code
function SignupForm() {
  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();

    const formData = new FormData(event.currentTarget);
    const email = String(formData.get("email") ?? "");

    submitSignup({ email });
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" type="email" defaultValue="" />
      <button type="submit">Sign up</button>
    </form>
  );
}

Uncontrolled inputs are often simpler for submit-only forms. Controlled inputs are better when React must react to every change.

Route-Aware Forms

Data routers provide route-aware forms that submit to route actions. The action owns the mutation.

Code
export async function action({ request }: ActionArgs) {
  const formData = await request.formData();
  const title = String(formData.get("title") ?? "");

  await createProject({ title });

  return redirect("/projects");
}

function NewProjectPage() {
  return (
    <Form method="post">
      <label>
        Title
        <input name="title" />
      </label>
      <button type="submit">Create project</button>
    </Form>
  );
}

Route forms are useful when the submit should participate in navigation, redirects, loader revalidation, route error boundaries, and browser history.

Actions

An action is the mutation handler for a route. It receives a request, reads form data, validates it, performs work, and returns a result.

Code
async function action({ request, params }: ActionArgs) {
  const formData = await request.formData();
  const name = String(formData.get("name") ?? "").trim();

  if (name.length === 0) {
    return {
      ok: false,
      errors: {
        name: "Name is required",
      },
    };
  }

  await updateTeam(params.teamId, { name });

  return {
    ok: true,
  };
}

Actions should enforce server-side validation and authorization. Client-side validation improves user experience, but it is not a security boundary.

Action Data and Validation Errors

Expected validation errors should usually be returned as action data, not thrown to an error boundary.

Code
function TeamForm() {
  const actionData = useActionData() as
    | { ok: false; errors: { name?: string } }
    | undefined;

  return (
    <Form method="post">
      <label htmlFor="name">Team name</label>
      <input
        id="name"
        name="name"
        aria-invalid={Boolean(actionData?.errors.name)}
        aria-describedby={actionData?.errors.name ? "name-error" : undefined}
      />
      {actionData?.errors.name && (
        <p id="name-error" role="alert">
          {actionData.errors.name}
        </p>
      )}
      <button type="submit">Save</button>
    </Form>
  );
}

Use field-level errors when the user can fix specific fields. Use form-level errors for cross-field or business-rule failures.

Client Validation vs Server Validation

Client validation improves feedback:

Code
const emailLooksValid = email.includes("@");

Server validation enforces correctness:

Code
if (!isValidEmail(email)) {
  return { errors: { email: "Enter a valid email address" } };
}

Use both when appropriate:

  • Client validation: fast feedback, disabled submit buttons, inline hints.
  • Server validation: real authority, security, business rules, database uniqueness.

Do not trust client validation alone. Users can bypass JavaScript, edit requests, or submit stale UI.

Mutation States

Mutation state describes where a write operation is in its lifecycle.

Common states:

  • idle: no mutation is currently running.
  • pending or submitting: the mutation is in progress.
  • success: the mutation completed successfully.
  • error: the mutation failed.

Manual state example:

Code
type MutationStatus = "idle" | "pending" | "success" | "error";

const [status, setStatus] = useState<MutationStatus>("idle");

async function save() {
  setStatus("pending");

  try {
    await saveProfile();
    setStatus("success");
  } catch {
    setStatus("error");
  }
}

Prefer one status value over several booleans:

Code
const isSaving = status === "pending";
const hasError = status === "error";

Several booleans can contradict each other.

Pending UI

Pending UI tells the user that work is in progress.

Code
function SaveButton() {
  const navigation = useNavigation();
  const saving = navigation.state === "submitting";

  return (
    <button type="submit" disabled={saving}>
      {saving ? "Saving..." : "Save"}
    </button>
  );
}

Good pending UI:

  • Disables duplicate submissions when appropriate.
  • Shows progress near the action.
  • Keeps stable content visible.
  • Uses clear labels such as Saving..., Creating..., or Deleting....
  • Does not block unrelated parts of the page.

Avoid replacing the whole screen with a spinner for a small inline save.

Fetchers for In-Place Mutations

Fetchers submit to loaders or actions without causing navigation. They are useful for inline updates, toggles, search suggestions, and background form interactions.

Code
function TaskTitle({ task }: { task: Task }) {
  const fetcher = useFetcher();
  const busy = fetcher.state !== "idle";

  return (
    <fetcher.Form method="post" action={`/tasks/${task.id}`}>
      <input name="title" defaultValue={task.title} />
      <button type="submit" disabled={busy}>
        {busy ? "Saving..." : "Save"}
      </button>
    </fetcher.Form>
  );
}

Use a normal route form when the submit should navigate. Use a fetcher when the submit should update part of the current page.

Fetcher Data for Validation

Fetcher action results are available through fetcher.data.

Code
function InlineTitleEditor({ task }: { task: Task }) {
  const fetcher = useFetcher<{
    ok: boolean;
    error?: string;
  }>();

  return (
    <fetcher.Form method="post" action={`/tasks/${task.id}`}>
      <input name="title" defaultValue={task.title} />
      <button type="submit">Save</button>
      {fetcher.data?.error && (
        <p role="alert">{fetcher.data.error}</p>
      )}
    </fetcher.Form>
  );
}

This keeps validation feedback local to the inline mutation rather than replacing the whole route.

Optimistic Updates

An optimistic update shows the expected result before the server confirms it.

Code
function TaskToggle({ task }: { task: Task }) {
  const fetcher = useFetcher();
  const optimisticDone =
    fetcher.formData?.get("done") === "true" ? true : task.done;

  return (
    <fetcher.Form method="post" action={`/tasks/${task.id}/toggle`}>
      <input type="hidden" name="done" value={String(!task.done)} />
      <button type="submit">
        {optimisticDone ? "Done" : "Not done"}
      </button>
    </fetcher.Form>
  );
}

Optimistic UI is strongest when:

  • The user action is likely to succeed.
  • The next state is easy to predict.
  • The operation is reversible.
  • Failure can be clearly shown.
  • Server revalidation will reconcile the final state.

Avoid optimistic UI for high-risk operations such as payments, irreversible deletes, or permission-sensitive changes unless the product intentionally supports rollback.

Optimistic UI vs Optimistic Cache Updates

There are two broad approaches:

  • Show optimistic UI locally from pending form data or mutation variables.
  • Update a shared client cache optimistically and roll back on failure.

Local optimistic UI is simpler:

Code
const optimisticTitle =
  fetcher.formData?.get("title")?.toString() ?? task.title;

Cache-level optimistic updates are useful when several components must reflect the same optimistic change.

Code
const mutation = useMutation({
  mutationFn: updateTask,
  onMutate: async (updatedTask) => {
    await queryClient.cancelQueries({ queryKey: ["tasks"] });
    const previousTasks = queryClient.getQueryData<Task[]>(["tasks"]);

    queryClient.setQueryData<Task[]>(["tasks"], (tasks = []) =>
      tasks.map((task) =>
        task.id === updatedTask.id ? { ...task, ...updatedTask } : task
      )
    );

    return { previousTasks };
  },
  onError: (_error, _variables, context) => {
    queryClient.setQueryData(["tasks"], context?.previousTasks);
  },
  onSettled: () => {
    return queryClient.invalidateQueries({ queryKey: ["tasks"] });
  },
});

Cache optimism is more powerful but requires rollback and concurrency discipline.

Revalidation and Invalidation

After a mutation, the UI needs authoritative server state.

Route actions can trigger loader revalidation. Query libraries use invalidation.

Code
const mutation = useMutation({
  mutationFn: createTodo,
  onSettled: () => {
    return queryClient.invalidateQueries({ queryKey: ["todos"] });
  },
});

Revalidation matters because optimistic UI is a guess. The server may normalize data, reject changes, assign IDs, update timestamps, or apply business rules.

Accessibility for Forms and Mutation Feedback

Validation and mutation states should be accessible.

Good practices:

  • Associate labels with inputs.
  • Use aria-invalid for invalid fields.
  • Use aria-describedby to connect fields to error messages.
  • Use role="alert" or live regions for important async errors.
  • Keep focus management intentional after submission.
  • Avoid disabling a submit button without explaining why if the reason is not obvious.

Example:

Code
<input
  id="email"
  name="email"
  aria-invalid={Boolean(errors.email)}
  aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && (
  <p id="email-error" role="alert">
    {errors.email}
  </p>
)}

Common Mistakes

Common mistakes include:

  • Handling form submission only with button onClick.
  • Trusting client validation without server validation.
  • Throwing route errors for normal validation failures.
  • Using several mutation booleans that can contradict each other.
  • Not disabling or guarding duplicate submissions.
  • Showing global spinners for small inline mutations.
  • Applying optimistic updates without rollback or reconciliation.
  • Forgetting to revalidate or invalidate after mutation.
  • Using optimistic UI for irreversible or high-risk actions.
  • Losing user input when validation fails.
  • Making errors inaccessible to assistive technology.

Best Practices

Use these rules of thumb:

  • Use semantic forms and onSubmit.
  • Validate on the client for speed and on the server for authority.
  • Return expected validation errors as action or mutation data.
  • Use route actions for navigation-oriented mutations.
  • Use fetchers for in-place mutations.
  • Model mutation state as one status value.
  • Show pending UI close to the user action.
  • Use optimistic UI only when the next state is predictable and recoverable.
  • Revalidate or invalidate after successful mutations.
  • Keep error and success messages accessible.

Interview Practice

PreviousError states and retry UX for failed requestsNext UpNested routes, layouts, params, and route boundaries