
@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.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.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 failureconst 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);}}
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.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>);}
UnprocessableEntity's JSON body nests fieldErrors, fields, and
data under a top-level cause key (see actionData.fieldErrors directly is the most common mistake
when wiring this up — it's actionData.cause.fieldErrors.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.MaskedInput has no orientation prop, unlike most of its siblings) — check each component's own reference page for its exact prop list.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>);}
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>
Element's name must match the field's name (and therefore the schema key and the fieldErrors key) exactly.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.useAutomation(actionData) once, anywhere that re-renders when the action response changes. It reads the same response object end to end:closeModal is truthy (via useModal's closeAll).data.scrollTo, per step 3.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}
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.?closeModal=true&message=...), use scope prefix if more than one form/filter shares the same page,
to avoid key collisions.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>);}
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.