OfficeGest
Form Builder

Submission & Error Handling

SubmitButton, SubmitButtonGroup, ErrorSummary, and capturing submission errors.

Overview

Every form built with useFormBuilder shares one submission lifecycle. Submission is always imperative — the builder does not wrap a native <form> element, so submit primitives call form.handleSubmit() on click rather than relying on a native submit event.

This page covers the three primitives that drive that lifecycle and the state slice that reports its outcome:

Primitive / statePurpose
<form.SubmitButton>A single submit-bound button. Disables itself while validating or submitting.
<form.SubmitButtonGroup>A primary action plus N secondary actions (e.g. "Save", "Save and New"), each with an optional post-success callback.
<form.ErrorSummary>An opt-in list of invalid fields with click-to-focus. Hidden while the form is valid.
submitErrorThe error thrown by your onSubmit / mutation, surfaced through form.useFormState.

All four read from the same TanStack Form store, so they stay consistent automatically: while a submit is in flight every button is disabled, and the moment validation fails the summary appears.

Place these primitives anywhere inside <form.Provider>. They read the form through React context, so they don't need props wiring them to the form instance.


SubmitButton

<form.SubmitButton> is a shadcn Button bound to the form's lifecycle. It subscribes to the form's dirty, submitting, and validating state to drive its own disabled flag.

Props

PropTypeDefaultDescription
alwaysEnabledbooleanfalseKeep the button enabled even when the form is pristine (no changes since load).
childrenReactNodecommon.saveThe label. A string is treated as an i18n key and resolved; any other node renders as-is.

Disabled logic

The button is disabled when any of these is true:

  • the form is currently submitting (isSubmitting);
  • a sync or async validator is in flight (isValidating);
  • the form is pristine and alwaysEnabled is not set.

The pristine gate is the important default: a freshly loaded "edit" form starts disabled until the user changes something. For "new entry" flows — where submitting with the default values is legitimate — pass alwaysEnabled.

<form.Provider>
  <form.Fields names={["name", "note"]} />
  <form.SubmitButton>actions.save</form.SubmitButton>
</form.Provider>
<form.SubmitButton alwaysEnabled>actions.create</form.SubmitButton>

A string child is an i18n key, not literal text. <form.SubmitButton>Save</form.SubmitButton> resolves the key Save through i18next — it shows the word "Save" only because i18next falls back to the key when no translation exists (as in the playground, which has no translation CDN). In real forms pass a real key (e.g. common.save), or pass a non-string node if you genuinely need literal markup. Omitting children uses common.save.

The button gates on async validators too. If a field has a validateAsync check in flight, the button stays disabled until it resolves — the user can't push a save through an as-yet-unconfirmed value.


SubmitButtonGroup

<form.SubmitButtonGroup> renders a primary action plus an optional list of secondary actions, all sharing one submit lifecycle. The classic use is "Save" / "Save and New" / "Save and Close" — three buttons that all submit the same form but differ in what happens after a successful save.

Props

PropTypeDescription
primarySubmitButtonActionThe primary action, rendered at the right edge (the terminus of the Z-reading pattern).
secondaryReadonlyArray<SubmitButtonAction>Optional secondary actions, rendered to the left of the primary.
alwaysEnabledbooleanSame pristine-override as SubmitButton, applied to all buttons.

Each action has this shape:

type SubmitButtonAction = {
  label: string;        // i18n key, resolved at render
  onAfter?: () => void; // fires ONLY after a successful submit
};

There is no "behavior" or "mode" discriminator. The only difference between actions is the onAfter callback you attach. If you want an action to do nothing special after saving (just submit and stay), omit onAfter.

onAfter fires only on success

onAfter runs only when the submission actually succeeded. Internally the group awaits form.handleSubmit() and then checks the form's isSubmitSuccessful flag — handleSubmit resolves even when validation blocks the submit, so the success check is what distinguishes "the user's onSubmit ran to completion" from "validation stopped us before we got there".

This is what makes "Save and New" safe: if validation fails, the form is not reset, so the user doesn't lose their work to a reset that fired on a blocked submit.

Worked example — Save and New

<form.Provider>
  <form.Fields names={["title"]} />
  <form.SubmitButtonGroup
    primary={{label: "common.save"}}
    secondary={[
      {
        label: "actions.saveAndNew",
        onAfter: () => {
          form.reset();
        },
      },
      {
        label: "actions.saveAndClose",
        onAfter: () => {
          closeSheet();
        },
      },
    ]}
  />
</form.Provider>

"Save and New" calls form.reset() in its onAfter, clearing the form back to its defaults so the user can immediately enter the next record. "Save and Close" leaves the form alone and instead closes its parent sheet. Plain "Save" has no onAfter, so it submits and leaves the user where they are.

onAfter vs resetOnSuccess — pick the right reset. The hook accepts a top-level resetOnSuccess?: boolean option that resets the form after any successful submit. A per-action onAfter calling form.reset() resets only for that action. "Save and New" uses onAfter precisely because you do not want plain "Save" to also wipe the form — resetOnSuccess would reset on every button.

All buttons in the group share one disabled state (derived from isSubmitting, isValidating, and the pristine gate). Because the submission lifecycle is a single shared store, only one click can ever be in flight — clicking a second button while a submit is running is structurally impossible, since they're all disabled.


ErrorSummary

<form.ErrorSummary> is an opt-in list of every invalid field, each rendered as a button that focuses and scrolls to the corresponding input. It takes no props.

  • It renders nothing while the form is valid.
  • When there are errors, it renders a destructive-variant alert titled with the error count.
  • Each entry shows the field's resolved label and its first error message — the same message the inline field error already shows, gathered into one navigable list. This is useful for long forms where the failing field may be scrolled off-screen.
  • Clicking an entry focuses the field's input and scrolls it into view.
<form.Provider>
  <form.Fields names={["name", "note"]} />
  <form.ErrorSummary />
  <form.SubmitButton>common.save</form.SubmitButton>
</form.Provider>

Place it just above the submit button so the user lands on the summary after a failed submit attempt.

Array and nested field paths

Field error paths use bracket notation for array rows — contacts[0].value. The summary collapses these to the array's root label when looking up the display label, so an item-level error reads as "Contacts — value is required" rather than naming the specific row's field. The inline error next to the field stays exact; only the summary label is collapsed.

Accessibility. The summary is a labelled navigation region (role="region"), not a second alert. The inline field errors already announce as alerts on submit; if the summary announced too, screen readers would get two competing announcements for the same failure. The summary is there to navigate, not to re-announce.

The inert-submit trap. A field hidden by a visible predicate keeps its stored value and is still validated on submit — hiding a field does not skip its validation. If that hidden field is invalid (e.g. an empty z.string().min(1)), submit fails with no visible error, because the field is unmounted and has no inline affordance. The button looks like it does nothing.

Two parts to the fix:

  1. Make the schema branch tolerant of the hidden state — .optional() or .nullable() (or model the branch with z.discriminatedUnion) so a hidden field doesn't block submit.
  2. Add <form.ErrorSummary> — it lists the otherwise-invisible error so the user can at least see what is wrong. (Clicking it no-ops gracefully when the field is unmounted, rather than throwing.)

Capturing submission errors

When your onSubmit handler (or bound mutation) throws, the builder captures the thrown error and exposes it as submitError on the form state. It is typed Error | null.

const submitError = form.useFormState((state) => state.submitError);

How it's captured

TanStack Form does not put errors thrown by your submit handler into its form-level error state. The builder bridges that gap: in its internal onSubmit it runs your handler inside a try/catch, and on failure it:

  1. stores the thrown error (coerced to an Error) so it can surface via submitError;
  2. invokes your optional onError(err) callback;
  3. re-throws, so TanStack records the submit as unsuccessful (isSuccess stays false).

Because the error is re-thrown, a failed submit never triggers onAfter, onSuccess, or resetOnSuccess.

onError vs submitError. onError is an imperative side-effect hook — use it for toasts, logging, or telemetry. submitError is reactive state — use it to render an inline error message in the form's body. They fire from the same catch block; use whichever (or both) fits the surface you're updating.

Worked example — render the error inline

This mirrors the submission-states playground, where a mock backend can resolve, reject, or hang. A rejected submit surfaces through submitError:

const form = useFormBuilder({
  formId: "playground-submission",
  schema: mockSchema,
  fields: mockFields,
  defaultValues: {name: "", note: ""},
  onSubmit: async (values) => {
    await saveToServer(values); // throws on failure
  },
});

const submitError = form.useFormState((state) => state.submitError);
const isSubmitting = form.useFormState((state) => state.isSubmitting);

return (
  <>
    <form.Provider>
      <form.Fields names={["name", "note"]} />
      <form.ErrorSummary />
      <form.SubmitButton>common.save</form.SubmitButton>
    </form.Provider>

    {submitError ? (
      <div className="rounded-md border border-destructive bg-destructive/5 p-3 text-sm text-destructive">
        {submitError.message}
      </div>
    ) : null}
  </>
);

While the handler is in flight, isSubmitting is true, so <form.SubmitButton> disables itself — the user can't double-submit. If the handler hangs, the button stays disabled; provide an escape hatch (a secondary form.reset() button) if a hang is a real possibility in your flow.

Two error channels, two surfaces. submitError reports a failed submission (the server said no). <form.ErrorSummary> reports failed validation (the form said no before it ever reached the server). A submit can fail validation without ever calling your onSubmit, in which case there's a summary but no submitError. Render both surfaces to cover both failure modes.