Skip to content

Error Handling ​

The server might reject submitted data. Pass errors back to the form and it handles display.

Field-level errors ​

Pass a { fieldKey: message } object. The error shows on the field, and the form navigates to the step containing it.

vue
<script setup>
import { ref } from 'vue';

const serverErrors = ref({});

async function onSubmit(values) {
  try {
    await api.submitForm(values);
  } catch (err) {
    // Server returns: { email: "Already registered" }
    serverErrors.value = err.fieldErrors;
    throw err;
  }
}
</script>

<template>
  <FormRenderer
    :definition="definition"
    :errors="serverErrors"
    @submit="onSubmit"
  />
</template>
tsx
function MyForm() {
  const [errors, setErrors] = useState({});

  async function handleSubmit(values) {
    try {
      await api.submitForm(values);
    } catch (err) {
      // Server returns: { email: "Already registered" }
      setErrors(err.fieldErrors);
      throw err;
    }
  }

  return (
    <FormRenderer
      definition={definition}
      errors={errors}
      onSubmit={handleSubmit}
    />
  );
}

In a multi-step form, if the error is on a field in step 1 and the user is on step 3, the form auto-navigates back to step 1. If you catch a failed save to map its field errors, rethrow it so onAfterSubmit does not run as if saving succeeded. Rejections reach onError, or the form-level error banner when that callback is omitted. With onError, the banner does not show the rejection; display it yourself.

Top-level errors ​

If the error targets a field that doesn't exist (or is hidden), it shows as a banner above the action buttons.

Covers cases like:

  • Account suspended
  • Rate limit exceeded
  • Generic server errors
ts
// Server returns an error for a non-existent field key
setErrors({ _general: 'Your account has been temporarily suspended.' });

The banner renders between the fields and the Submit/Continue buttons.

Async step validation errors ​

See Async Step Validation for how onStepValidate errors display and how to handle network failures.

Loading state ​

React awaits the Promise returned by onSubmit, and Vue the one returned by the @submit listener. FormRenderer and HeadlessFormRenderer keep fields and actions busy during awaited navigation, submission and lifecycle hooks.

vue
<FormRenderer
  :definition="definition"
  @submit="save"
  :on-error="handleRequestError"
/>
tsx
<FormRenderer
  definition={definition}
  onSubmit={save}
  onError={handleRequestError}
/>

Here save(values) returns the request Promise and rejects on failure. No separate loading state is required for that request.

Keep parent-owned loading for other work that should disable the form:

vue
<script setup>
import { ref } from 'vue';

const isSubmitting = ref(false);

async function onSubmit(values) {
  isSubmitting.value = true;
  try {
    await api.submitForm(values);
  } catch (error) {
    handleRequestError(error);
  } finally {
    isSubmitting.value = false;
  }
}
</script>

<template>
  <FormRenderer
    :definition="definition"
    :loading="isSubmitting"
    @submit="onSubmit"
  />
</template>

While busy, the default fields and action buttons are disabled. The form has aria-busy; the React primary button also has it. The built-in actions do not render a spinner; use a custom actions component if you need one. See lifecycle ordering and after-hook failures.

Loading button ​

A custom actions component receives loading and the resolved primaryLabel:

tsx
import { FormRenderer, type FormActionsProps } from '@formhaus/react';

function SavingActions({ primaryLabel, showPrimary = true, showBack, backLabel, loading, onPrimary, onPrev }: FormActionsProps) {
  return (
    <div className="actions">
      {showBack && <button type="button" disabled={loading} onClick={onPrev}>{backLabel}</button>}
      {showPrimary && (
        <button type="button" disabled={loading} aria-busy={loading} onClick={onPrimary}>
          {loading ? 'Saving…' : primaryLabel}
        </button>
      )}
    </div>
  );
}

<FormRenderer definition={definition} onSubmit={save} actionsComponent={SavingActions} />

loading is true while onSubmit, onStepValidate or a lifecycle hook runs. All props, and the Vue version, are in Custom Actions & Progress.

Field-level loading ​

Custom renderers built around FormEngine or useFormEngine can mark one field as loading:

ts
engine.setFieldLoading('city', true);
try {
  const result = await lookupCity(engine.values.zipCode);
  engine.setValue('city', result.name);
} finally {
  engine.setFieldLoading('city', false);
}

FormRenderer owns its engine. Calling useFormEngine() next to a FormRenderer creates a separate instance and does not change the rendered form. Use the hook when you are building the renderer yourself. See Values and validation for the underlying methods.

Next steps ​

Formhaus is MIT licensed. Legal