arkynChangelogGuides
arkyn
docs / guides / how-to-build-forms-with-validation

Build forms with server-side validation

Arkyn splits form validation across two packages: @arkyn/server validates the payload and returns a structured error shape, and @arkyn/components reads that same shape to light up the right fields, scroll to the first error, and fire a toast. Neither side is complicated on its own, but the contract between them isn't written down on any single reference page — this guide walks the whole loop end to end.

The shared contract

Everything in this guide hinges on one object shape moving from the server to the client:

ts

{
fieldErrors: { email: "Invalid email", name: "Name is required" },
fields: { email: "not-an-email", name: "" },
data: { scrollTo: "email" },
}
  • fieldErrors is read by every Arkyn form field via useForm(), keyed by the field's name.
  • fields lets you repopulate the form with whatever the user typed.
  • data.scrollTo names the first invalid field, so the UI can scroll to it.
@arkyn/server's SchemaValidator and formParse produce this shape for you; you just need to route it to the right places on the client.

1. Validate on the server

Define a Zod schema and validate the incoming form data with SchemaValidator. Its formValidate method throws UnprocessableEntity — already carrying fieldErrors, fields, and data.scrollTo — the moment validation fails, so a single try/catch (or errorHandler, see Handle API responses and logging) covers every exit path.

typescript

import { z } from "zod";
import { SchemaValidator } from "@arkyn/server/schemaValidator";
import { Created } from "@arkyn/server/created";
import { errorHandler } from "@arkyn/server/errorHandler";
const schema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email"),
});
const validator = new SchemaValidator(schema);
export async function action({ request }: Route.ActionArgs) {
try {
const formData = Object.fromEntries(await request.formData());
// Throws UnprocessableEntity (with fieldErrors/fields/scrollTo) on failure
const data = validator.formValidate(formData, "Please fix the errors below");
await createCustomer(data);
return new Created("Customer created successfully!", {
closeModal: true,
}).toJson();
} catch (error) {
return errorHandler(error);
}
}
Prefer not to throw?
formParse([formData, schema]) is the non-throwing equivalent — it returns { success, fieldErrors, fields } or { success, data } instead of throwing. Use whichever fits your control flow; the shape handed to the client is the same either way.

2. Pass fieldErrors into FormProvider

On the client, wrap the form in FormProvider and feed it the fieldErrors from the action response (React Router's useActionData(), or your fetcher's equivalent). Every field inside reads from this context automatically.

tsx

import { FormProvider } from "@arkyn/components/formProvider";
import { Input } from "@arkyn/components/input";
import { useActionData } from "react-router";
export default function CreateCustomerForm() {
const actionData = useActionData<typeof action>();
return (
<FormProvider fieldErrors={actionData?.cause?.fieldErrors}>
<form method="post">
<Input name="name" label="Name" showAsterisk />
<Input name="email" label="Email" showAsterisk />
<button type="submit">Create</button>
</form>
</FormProvider>
);
}
fieldErrors lives under cause
UnprocessableEntity's JSON body nests fieldErrors, fields, and data under a top-level cause key (see
UnprocessableEntity
). Reading actionData.fieldErrors directly is the most common mistake when wiring this up — it's actionData.cause.fieldErrors.

Which components read fieldErrors automatically

Input, Select, MultiSelect, Textarea, Checkbox, RadioGroup, Switch, CurrencyInput, MaskedInput, PhoneInput, and RichText all follow the same rule: pass errorMessage explicitly to force a message, or omit it and let the component fall back to fieldErrors[name] from useForm(). You only need FormProvider once per form — not per field.
Field-level props aren't perfectly identical across every component (for example MaskedInput has no orientation prop, unlike most of its siblings) — check each component's own reference page for its exact prop list.
If you're composing a fully custom field instead of using one of the built-ins, read fieldErrors yourself and render it with the Field family:

tsx

import { useForm } from "@arkyn/components/useForm";
import { FieldWrapper } from "@arkyn/components/fieldWrapper";
import { FieldLabel } from "@arkyn/components/fieldLabel";
import { FieldError } from "@arkyn/components/fieldError";
function CustomField({ name, label }: { name: string; label: string }) {
const { fieldErrors } = useForm();
return (
<FieldWrapper>
<FieldLabel htmlFor={name}>{label}</FieldLabel>
<input id={name} name={name} />
<FieldError>{fieldErrors?.[name]}</FieldError>
</FieldWrapper>
);
}

3. Scroll to the first error

data.scrollTo only does something if you tell it what to scroll to. Internally, Arkyn's useAutomation hook calls react-scroll's imperative scroller.scrollTo(name, ...) — and react-scroll only knows how to find an element that has been explicitly registered as a scroll target. No Arkyn field wraps itself in one of these automatically, so you need to do it yourself with react-scroll's Element:

tsx

import { Element } from "react-scroll";
import { Input } from "@arkyn/components/input";
<Element name="email">
<Input name="email" label="Email" showAsterisk />
</Element>
The string passed to Element's name must match the field's name (and therefore the schema key and the fieldErrors key) exactly.
Easy to miss
Skipping the Element wrapper is the most common reason scrollTo "doesn't work" — the hook runs fine, react-scroll just has nothing registered under that name to scroll to.

4. Automate toasts and modal close with useAutomation

Call useAutomation(actionData) once, anywhere that re-renders when the action response changes. It reads the same response object end to end:
  • Closes every open modal if closeModal is truthy (via useModal's closeAll).
  • Scrolls to data.scrollTo, per step 3.
  • Fires a toast: on success it shows message; on failure it prioritizes the first fieldErrors message over the generic message (so users see something actionable, not just "Unprocessable entity").

tsx

import { useAutomation } from "@arkyn/components/useAutomation";
import { useActionData } from "react-router";
function CreateCustomerForm() {
const actionData = useActionData<typeof action>();
useAutomation(actionData);
// ...rest of the form
}
This requires ModalProvider and ToastProvider ancestors somewhere above the form (typically once near your app root) — useAutomation throws if either is missing. See DrawerProvider, ModalProvider, and ToastProvider for the provider setup itself.
Full-page navigations instead of fetchers?
If your flow relies on URL search params rather than an in-memory action response (e.g. a redirect back to the same page with ?closeModal=true&message=...), use
useSearchAutomation
instead — same automation, driven by the URL. Pair it with useScopedParams and a scope prefix if more than one form/filter shares the same page, to avoid key collisions.

Putting it together

tsx

import { FormProvider } from "@arkyn/components/formProvider";
import { Input } from "@arkyn/components/input";
import { useAutomation } from "@arkyn/components/useAutomation";
import { useActionData } from "react-router";
import { Element } from "react-scroll";
export default function CreateCustomerForm() {
const actionData = useActionData<typeof action>();
useAutomation(actionData);
return (
<FormProvider fieldErrors={actionData?.cause?.fieldErrors}>
<form method="post">
<Element name="name">
<Input name="name" label="Name" showAsterisk />
</Element>
<Element name="email">
<Input name="email" label="Email" showAsterisk />
</Element>
<button type="submit">Create</button>
</form>
</FormProvider>
);
}

Notes

  • SchemaValidator.formAsyncValidate() is the async counterpart of formValidate(), for schemas with async refinements (e.g. checking uniqueness against the database) — same shape, await it.
  • data on UnprocessableEntity is passed through as-is; scrollTo is a convention useAutomation interprets, not something @arkyn/server itself acts on. You can add your own keys to data and read them client-side the same way.
  • useAutomation suppresses the toast for the literal message "Unprocessable entity" (the default message when none is passed to the validator) so a generic fallback string doesn't get shown as if it were useful — pass a real message to formValidate/formAsyncValidate if you want the toast to say something specific.
  • For the full picture of how thrown responses become HTTP responses on the server side, see Handle API responses and logging.
Related in Guides
On this page