Submission Handling

function LoginForm() {
  const form = useForm({
    initialValue: { email: "", password: "" },
    onSubmit: async (form) => {
      await login(form.value);
    },
    onSubmitError: (form, error) => {
      toast.error("Failed to log in");
    },
  });
 
  return <form onSubmit={form.handleSubmit}>{/* ... */}</form>;
}

handleSubmit:

  1. Waits out any pending validation.
  2. If the form is invalid, marks it touched (so errors on never-blurred fields become visible) and calls onSubmitInvalid, then returns.
  3. Otherwise sets submitting to true, calls onSubmit, and falls back to onSubmitError if it throws/rejects.

A no-op on a re-entrant call while already submitting. The event parameter is optional and used only for preventDefault(), so the same call works from a React Native onPress, any caller with no event, or a web <form onSubmit> as shown above.

Server-side validation errors

Some checks only run on the server: uniqueness, cross-record rules, anything needing data the client doesn't have. When onSubmit's request comes back with per-field errors (an HTTP 422, typically), hand them to form.setErrors:

const form = useForm({
  initialValue: { email: "", password: "" },
  onSubmit: async (form) => {
    const res = await fetch("/api/sign-up", {
      method: "POST",
      body: JSON.stringify(form.value),
    });
    if (res.status === 422) {
      const { errors } = await res.json(); // { email: "Already registered" }
      form.setErrors(errors);
    }
  },
});

setErrors takes a flat, dot-joined path-to-message map, the same shape a schemaValidator produces: "email", "address.city", or "" for a message about the form as a whole. It's on every node, so form.setErrors(...) addresses the whole tree while form.field("address").setErrors(...) scopes to a subtree.

Each message:

  • Lands only on a field already created with field(); a path with no field is ignored.
  • Marks that field touched, so it shows even if the user never focused it.
  • Outranks a schemaValidator error on the same field, and clears the next time that field's value changes. Re-submitting is what refreshes it.

None of this is SSR-specific: it's the same call whether the form is server-rendered or a plain client-side SPA. For a live "is this taken?" check that runs while the user types rather than at submit, use asyncValidator instead.

Disabling the submit button while submitting

submitting (and dirty, for a "nothing to save" state) are ordinary reactive state, so gate the button like any other field property:

<Watch api={form} select={(f) => [f.submitting, f.dirty] as const}>
  {(form, [submitting, dirty]) => (
    <button type="submit" disabled={submitting || !dirty}>
      Save
    </button>
  )}
</Watch>;

Disabling the whole form while submitting

Set form.disabled = true around onSubmit's work. disabled cascades from a field down through every already-registered descendant, so form.disabled = true reaches every field in the tree without watching submitting anywhere:

const form = useForm({
  initialValue: { email: "", password: "" },
  onSubmit: async (form) => {
    form.disabled = true;
    try {
      await login(form.value);
    } finally {
      form.disabled = false;
    }
  },
});

disabled on its own only skips validation; it doesn't reach the DOM by itself. For it to actually disable an input, the component rendering that input has to read its own field's disabled and fold it into whatever disabled prop the caller passed in, the same way TextField does (see Basic):

const isDisabled = disabled || field.disabled;

Each TextField is already subscribed to just its own field via useWatch, so disabling a 50-field form during submit re-renders only the fields whose disabled actually flipped, not LoginForm itself.

What's next

  • FormApi — full reference on JSR