---
url: "https://vueda.dev/v3/guides/form-validation-and-errors.md"
title: "Handle Form Validation and Server Errors"
---

# Handle Form Validation and Server Errors

This guide gets server validation errors into form fields, clears them when the user edits, and blocks submission only on local errors. The stock form views already do all of this: [ViewCreate](/v3/reference/api/vue/ViewCreate.md) and [ViewUpdate](/v3/reference/api/vue/ViewUpdate.md) through [useObjectForm](/v3/reference/api/js/use/useObjectForm/functions/useObjectForm.md), and the action views through [ActionForm](/v3/reference/api/vue/ActionForm.md). Follow these steps for a custom form, a custom endpoint, or an error payload that the defaults do not render.

[Form State and Validation Lifecycle](/v3/core-concepts/form-state-and-validation-lifecycle.md) describes the form state that these steps write to. [Error and Validation Contract](/v3/core-concepts/error-and-validation-contract.md) describes the response shapes and the client error class that each status becomes. To hold a valid write until the user confirms a warning, see [Require Confirmation Before a Write](/v3/guides/require-write-confirmation.md).

## Before You Begin

- The form calls [useForm](/v3/reference/api/js/use/useForm/functions/useForm.md) to create its [Form Context](/v3/reference/glossary.md#form-context), and each field calls [useField](/v3/reference/api/js/use/useField/functions/useField.md). [FormField](/v3/reference/api/vue/FormField.md) and the other VUEDA field components call `useField` for you.
- The endpoint answers a validation failure with HTTP 400. The next section covers the server side.

## Return Errors the Client Can Map

On the server, raise [VuedaValidationError](/v3/reference/api/py/vueda.core.exceptions/VuedaValidationError.md) with a dict keyed by field. [DRF's `ValidationError`](https://www.django-rest-framework.org/api-guide/exceptions/#validationerror) produces the same body for a dict. The client keys each message by its [Field Path](/v3/reference/glossary.md#field-path), so the key decides where the message appears:

| Payload                                            | Where the client shows it                                                                                                          |
| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `{"quantity": ["Must be positive."]}`              | Under the `quantity` field.                                                                                                        |
| `{"items": [{"quantity": ["Must be positive."]}]}` | Under the `quantity` field of the first `items` row (`items[0].quantity`).                                                         |
| `{"non_field_errors": ["Dates overlap."]}`         | In the form's `FormMessage`.                                                                                                       |
| `{"detail": "Invalid request."}`                   | In `ActionForm`'s validation summary under `detail`. `ViewCreate` and `ViewUpdate` show only their "Save Validation Failed" toast. |
| `{"9": {"count": ["Too high."]}}`                  | In `ActionForm`'s validation summary under the raw key `9.count`, with no object label.                                            |

For a rule that spans several fields, raise the errors as a list. VUEDA's exception handler puts a list under `non_field_errors`, which makes it a [Non-Field Error](/v3/reference/glossary.md#non-field-error):

```python
from vueda.core.exceptions import VuedaValidationError

raise VuedaValidationError(["The start date must be before the end date."])
```

The client does not label bulk errors by object. To tell the user which object failed, name it in the message text, for example `{"non_field_errors": ["Order 9: count is too high."]}`. A bulk delete answers a missing or refused row with `{"9": ["Object with pk=9 does not exist."]}`, which `ActionForm`'s summary lists under `9`.

## Classify Responses in a Custom Fetch Wrapper

Only a [ServerFeedbackError](/v3/reference/api/js/utils/errors/members/ServerFeedbackError.md) reaches form state. The stock write adapters throw its subclass [FormValidationError](/v3/reference/api/js/utils/errors/members/FormValidationError.md) on a 400, so a wrapper for a custom endpoint must do the same.

Pass the response to [readActionResponse](/v3/reference/api/js/utils/fetchSupport/functions/readActionResponse.md). It throws `FormValidationError` on a 400, [ConfirmationRequiredError](/v3/reference/api/js/utils/errors/members/ConfirmationRequiredError.md) on a 409, and [FetchError](/v3/reference/api/js/utils/errors/members/FetchError.md) on any other failure. [actionRequestHeaders](/v3/reference/api/js/utils/fetchSupport/functions/actionRequestHeaders.md) adds the CSRF and JSON headers:

```js
import { actionRequestHeaders, readActionResponse } from "@vueda/utils/fetchSupport.js";

export async function recalculateTotals(url, formValues) {
    const response = await fetch(url, {
        method: "POST",
        headers: actionRequestHeaders(),
        credentials: "include",
        body: JSON.stringify(formValues),
    });
    return readActionResponse(response, { messagePrefix: "Failed to recalculate totals" });
}
```

`readActionResponse` treats every `response.ok` status as success. To accept only certain codes, pass `successStatuses`, for example `new Set([204])` for a delete.

A 429 response from a rate limit has its own class, [RateLimitedError](/v3/reference/api/js/utils/errors/members/RateLimitedError.md). `ActionForm` reports it only in the failure toast, with the error's message as the description. The form shows no error and the submit button stays enabled, because the same submit succeeds once the limit resets. `readActionResponse` throws a plain `FetchError` on a 429. A `run-action` that makes its own requests can check for a 429 and reject with `RateLimitedError` to get the same behavior.

## Ingest Server Errors in a Custom Submit Handler

`useObjectForm` and `ActionForm` run this step for you. A form that submits on its own, such as a sign-in form, repeats it in its submit handler:

```js
import { ConfirmationRequiredError, ServerFeedbackError } from "@vueda/utils/errors.js";
import isEmpty from "lodash-es/isEmpty.js";
import omit from "lodash-es/omit.js";
import { nextTick } from "vue";

async function submit() {
    formContext.setAllTouched();
    await nextTick();
    const ignored = Object.keys(formContext.state.ignored).filter((path) => formContext.state.ignored[path] === true);
    const blockingErrors = Object.entries(formContext.state.errors)
        .filter(([path]) => !ignored.some((ignoredPath) => path === ignoredPath || path.startsWith(`${ignoredPath}.`)))
        .filter(([, codes]) => !isEmpty(omit(codes, "server")));
    if (blockingErrors.length) {
        return;
    }
    try {
        await submitFn(formContext.state.submittingValues);
    } catch (error) {
        if (error instanceof ServerFeedbackError && !(error instanceof ConfirmationRequiredError)) {
            formContext.handleServerFormValidationError(error);
            return;
        }
        throw error;
    }
}
```

1. [`setAllTouched`](/v3/reference/api/js/use/useForm/types/FormContext.md#setAllTouched) and `await nextTick()` let the required and `validate` checks run on fields that the user never touched.
2. Errors under the `server` code do not block, so the user can resubmit and the server checks again. Errors on an [Ignored Field](/v3/reference/glossary.md#ignored-field) do not block either.
3. [`submittingValues`](/v3/reference/api/js/use/useForm/types/FormContextRawState.md#submittingValues) leaves ignored fields out of the payload.
4. [`handleServerFormValidationError`](/v3/reference/api/js/use/useForm/types/FormContext.md#handleServerFormValidationError) writes the response's errors into the form as [Server Feedback](/v3/reference/glossary.md#server-feedback). A `ConfirmationRequiredError` belongs to the confirmation flow in [Require Confirmation Before a Write](/v3/guides/require-write-confirmation.md).

## Clear Related Server Errors on Blur

A field's server errors clear when the field blurs. When one field's value causes a server error on another field, set [`clearServerErrorDependents`](/v3/reference/api/vue/FormField.md#prop-clearServerErrorDependents) on the field that the user edits. `$parent` stands for the parent path of the blurred field, so inside an [Inline](/v3/reference/glossary.md#inline) it names the same row.

Set it in the model config's [`fieldProps`](/v3/reference/api/js/stores/storeModelConfig/types/ModelConfig.md#fieldProps) with [`setConfig`](/v3/reference/api/js/stores/storeModelConfig/methods/setConfig.md), keyed by field path. The [`fieldProps` prop of `FormModel`](/v3/reference/api/vue/FormModel.md#prop-fieldProps) takes the same map:

```js
import { storeModelConfig } from "@vueda/stores/storeModelConfig.js";

storeModelConfig().setConfig(
    { app: "shop", model: "order" },
    {
        fieldProps: {
            "line_items.sku": {
                clearServerErrorDependents: ["$parent.quantity", "$parent.price"],
            },
        },
    },
);
```

Blurring `line_items[2].sku` clears the server errors on `line_items[2].quantity` and `line_items[2].price`. Clearing goes one step: the dependents' own dependents keep their errors.

## Show Form-Level and Field Errors

`FormModel` renders the form-level messages and `FormField` renders each field's messages, so the stock views need no extra markup. In a custom form, place [FormMessage](/v3/reference/api/vue/FormMessage.md) inside the form context. `type="error"` shows non-field errors and `type="message"` shows non-field warnings:

```vue
<form @submit.prevent="submit">
    <form-message type="error" />
    <form-message type="message" />
    <!-- field components -->
</form>
```

`FormField` renders the label, help text, errors, and warnings around the widget in its default slot:

```vue
<script setup>
import FormField from "@vueda/form/form-model/FormField.vue";
import WidgetTextInput from "@vueda/widgets/WidgetTextInput.vue";
</script>

<template>
    <FormField name="email" label="Email">
        <WidgetTextInput />
    </FormField>
</template>
```

[Forms](/v3/reference/components/forms.md) describes how these messages look.

### Action Form Validation Summary

`ActionForm` shows a validation summary above its fields when a field error has no rendered field showing it. The summary lists errors for these fields:

- a field rendered with `hidden`
- a field inside a collapsed inline field set
- a field whose renderer failed
- a key that the form does not render

A form whose errors all render beside their fields shows no summary. Each row names its field by the label shown above the input. When no rendered field reports a label, the row uses the error key.

A custom field component tells the form whether it shows its own errors through the third argument to `useField`. The default is `true`. A component that renders no inline error messages passes a [`showsErrors`](/v3/reference/api/js/use/useField/types/UseFieldOptions.md#showsErrors) function that returns `false`, so that its errors move into the summary:

```js
const field = useField(props, emit, { showsErrors: () => false });
```

A field still counts as showing its errors when a slot override replaces its error rendering. Those slots are [`field(fieldName)errors`](/v3/reference/api/vue/FormField/slots.md#slot-field-fieldName-errors), `field-errors`, and a field set's [`field-set-level-chores`](/v3/reference/api/vue/FieldSetMany/slots.md#slot-field-set-level-chores). An override of those slots should render the errors that it receives.

The [`validation-summary`](/v3/reference/api/vue/ActionForm/slots.md#slot-validation-summary) slot replaces the summary. It receives `entries` (each `{ field, label, messages }`), `count`, and `title`, covering only the errors that the summary would list.

### Render Structured Feedback Objects

Most messages are strings. To send a message with structured data, such as a list of rows, send an object with a `detail` key under `non_field_errors`. The client keeps an entry with a `detail` key as one object. `FormMessage` renders these objects. Under a field key, `FieldMessage` renders only strings and objects with a `message` property, so a `detail` object there shows nothing.

```python
raise VuedaValidationError(
    {
        "non_field_errors": [
            {
                "detail": "Some rows are invalid:",
                "rows": rows,
            }
        ]
    }
)
```

By default, `FormMessage` renders each key of the object as a `name: value` line. To render the object your way, override the [default slot](/v3/reference/api/vue/FormMessage.md#slot-default), which receives each `message`, and match on its shape:

```vue
<form-message type="error">
    <template #default="{ message }">
        <template v-if="message && typeof message === 'object' && Array.isArray(message.rows)">
            <p>{{ message.detail }}</p>
            <ul>
                <li v-for="row in message.rows" :key="row">{{ row }}</li>
            </ul>
        </template>
        <template v-else>{{ message }}</template>
    </template>
</form-message>
```

Write one branch per object shape that your server sends.

## Verify

- Submitting invalid data shows each error under its field.
- Non-field errors appear in the form's `FormMessage`.
- Blurring a field that had a server error clears that error.
- With only server errors left, submitting sends the request again.
- In `ViewCreate`, `ViewUpdate`, or another `useObjectForm` form, an empty required field blocks submission with a "Pre-save Validation Failed" toast. [Form State and Validation Lifecycle](/v3/core-concepts/form-state-and-validation-lifecycle.md) describes when `ActionForm` blocks on local errors.
- Structured non-field objects render through your `FormMessage` slot override, or as `name: value` lines without one.
- In `ViewCreate` and `ViewUpdate`, a failed save scrolls to the non-field errors first, then to the first displayed field with an error.

## Troubleshooting

**Form feedback is empty after a failed request.** Check the response status. Only a 400 on a write becomes `FormValidationError`. A 403, a 500, or any error on a read becomes `FetchError` or [ListFilterError](/v3/reference/api/js/utils/errors/members/ListFilterError.md), which never reaches form state. [Error and Validation Contract](/v3/core-concepts/error-and-validation-contract.md) lists the class that each adapter throws.

**Server errors do not clear after editing a field.** Blur clears them, and a value change alone does not. Check that the field component calls [`FieldContext.blur()`](/v3/reference/api/js/use/useField/types/FieldContext.md#blur) when its input loses focus.

**A custom submit handler does not block on local errors.** Call `setAllTouched()` and `await nextTick()` before reading the errors, as in [Ingest Server Errors in a Custom Submit Handler](/v3/guides/form-validation-and-errors.md#ingest-server-errors-in-a-custom-submit-handler).
