Define the state contract first

Use a bounded state shape:

const initialState = { kind: "idle", message: "" };

// Expected outcomes returned by the action:
// { kind: "success", message: "Saved request R-42." }
// { kind: "validation_error", message: "Choose a review owner." }
// { kind: "service_error", message: "Could not save. Retry…" }

The client state communicates what happened. It does not decide whether the actor was authorized, the input was valid on the server, or the write was committed exactly once.

The tested model also gives each submission an increasing operation identifier. A completion can update state only when it matches the currently pending operation. That prevents a late result from an older attempt from overwriting a newer visible state.

Read pending state from the form

React's useFormStatus returns status for a parent form. The component calling it must be rendered inside that form.

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

  return (
    <button
      type="submit"
      aria-disabled={pending}
      onClick={(event) => {
        if (pending) event.preventDefault();
      }}
    >
      {pending ? "Saving…" : "Save request"}
    </button>
  );
}

The fixture uses aria-disabled plus an activation guard instead of removing the focused submit control from keyboard interaction while it is pending. The guard is essential: styling a button as disabled does not prevent submission.

If your interface uses the native disabled attribute, test what happens to focus in the browsers and assistive technologies you support. There is no universal reason to prefer preserving focus over native disabled behavior; the choice should be deliberate and tested.

Return expected failures as action state

React's useActionState provides the current returned state, action, and pending flag.

async function saveRequest(_previousState, formData) {
  const result = await api.save(formData);

  if (result.ok) {
    return { kind: "success", message: `Saved request ${result.id}.` };
  }
  if (result.code === "validation_error") {
    return { kind: "validation_error", message: result.safeMessage };
  }
  return {
    kind: "service_error",
    message: "Could not save. Retry when the service is available.",
  };
}

function RequestForm() {
  const [state, action, pending] = useActionState(saveRequest, initialState);

  return (
    <form
      action={action}
      onSubmit={(event) => {
        if (pending) event.preventDefault();
      }}
    >
      {/* render fields, submit control, and message regions */}
    </form>
  );
}

Guard the form submission as well as the button activation. That covers an implicit submission, such as pressing Enter in a field, while an earlier operation is still pending. The server must still enforce its own idempotency or duplicate-request policy.

Return failures the user can reasonably recover from. Unexpected programming errors should reach an error boundary rather than being mislabeled as ordinary validation.

Never echo raw server or exception messages into the interface. Return a safe user-facing message and keep diagnostic detail in an appropriately protected server-side record.

Mount the announcement regions before they change

W3C's role=status technique uses a status container that exists before its text is updated. W3C also documents the failure mode in which a visual status message cannot be programmatically determined.

Keep separate stable regions for routine status and urgent expected failure:

<section aria-label="Submission messages">
  <p role="status" aria-atomic="true">
    {pending ? "Saving request…" : state.kind === "success" ? state.message : ""}
  </p>

  <p role="alert" aria-atomic="true">
    {!pending && state.kind.endsWith("_error") ? state.message : ""}
  </p>
</section>

The containers remain mounted when empty. Their contents change after the user submits. role=status provides a polite status channel; role=alert is reserved here for an expected failure that requires attention.

Do not put buttons or recovery links inside the live-region message itself. Put interactive recovery controls next to it with clear labels.

Keep focus policy separate from announcement policy

A live region can communicate a result without moving focus. That is useful for routine pending and success messages because the user stays in the context where they acted.

Focus movement may be appropriate when:

  • a validation summary needs immediate navigation;
  • the original control disappeared after a route or modal change;
  • a destructive failure requires a specific recovery decision; or
  • the user explicitly requested navigation.

When moving focus, name the destination and test the reading order. Do not move focus merely because a toast or status message appeared.

Test transitions, not just the happy path

The P74 state model passed these eight tests:

Test Expected behavior
Begin One operation enters pending and submission becomes unavailable
Duplicate begin A second start is ignored while pending
Success The current operation reaches success through the status channel
Validation failure A safe expected message uses the alert channel
Service failure Failure remains visible and retryable
Retry A new increasing operation can begin after failure
Stale completion An older result cannot overwrite the newer pending state
Unknown result The model rejects an unrecognized completion kind

The browser fixture adds behavior the state model cannot prove:

  • React 19.2 bundles and renders;
  • the pending label and aria-disabled state appear;
  • status and alert containers exist before their messages;
  • success and two expected failures render in the intended channel;
  • repeated submission and narrow viewport behavior remain usable; and
  • the component does not deliberately move focus.

This is more specific than a generic loading and failure render policy and separate from deciding whether to reconcile or refetch server data.

Preserve the backend boundary

This interface pattern does not prove that a submission is safe or durable. The trusted service still needs to:

  • authenticate the actor;
  • authorize the exact action and record;
  • validate all input;
  • define idempotency or duplicate-request behavior;
  • commit or reject the write atomically;
  • return a documented result contract; and
  • retain appropriate diagnostic and audit evidence.

Client-side duplicate suppression reduces accidental activation. It is not a replacement for server-side idempotency.

Failure modes to include in review

  • The status node is mounted only after the message exists, so an assistive technology may miss the change.
  • A toast is visible but has no programmatic status role.
  • A routine success steals focus from the user's current task.
  • The submit control looks disabled but still activates.
  • A raw server error exposes implementation or sensitive detail.
  • An older response overwrites a newer retry.
  • The error disappears before the user can understand or recover from it.
  • Frontend state is mistaken for server authorization or persistence.

Frequently asked questions

Should every failure use role=alert?

No. Reserve the urgent channel for information that needs immediate attention. Less urgent state can use a polite status region or ordinary content. Test with the assistive technologies you support.

Should the submit button use disabled or aria-disabled?

Either can be appropriate. Native disabled prevents activation but may affect focus and form behavior. aria-disabled requires an explicit activation guard. Choose deliberately and test keyboard, pointer, and assistive behavior.

Does useFormStatus validate the server result?

No. It reports form submission status. The server contract determines whether the write was authorized, validated, committed, and safe to display.

Should focus move to the first invalid field?

It can when field-level recovery requires it, especially after a full validation summary. Routine pending and success messages normally do not require a context change. Test the complete keyboard and reading order.