UNPKG

@rsc-kit/mcp

Version:

An MCP server over what an rsc-kit build decided: the routes, why each one is static or not, and what it costs the browser.

176 lines (131 loc) • 5.05 kB
# Validation > Surfacing server-side validation errors in a form. Validation errors have one shape everywhere: a field name mapped to the messages for it. ```ts { title: ['The title must be at least 3 characters.'], body: ['The body is required.'] } ``` `ServerValidationError` carries that shape, and `<Form>` catch it and put it in `errors`. Nothing in a component has to know where the validation ran. ## In a form ```tsx title="src/components/PostForm.tsx" "use client"; import { Form } from '@rsc-kit/core/Form'; import { createPost } from '../actions'; export function PostForm() { return ( <Form action={createPost}> {({ error, pending }) => ( <> <input name="title" /> {error('title') && <p className="error">{error('title')}</p>} <textarea name="body" /> {error('body') && <p className="error">{error('body')}</p>} <button disabled={pending}>Create</button> </> )} </Form> ); } ``` No `try`/`catch`, and no state of your own. A successful submit clears `errors`; a failed one fills them. ## Outside a form Catch it yourself, wherever the call rejects with one: ```tsx "use client"; import { ServerValidationError } from '@rsc-kit/core/errors'; import { createPost } from '../actions'; try { await createPost(title, body); } catch (error) { if (error instanceof ServerValidationError) { setErrors(error.errors); } } ``` ## Producing one <Aside type="caution" title="The server half is the host's"> There is no validation library here, and no rule syntax to learn. What the engine defines is the error the client understands — deciding that input is invalid is your code's job, or your host's. </Aside> ### Before it is submitted `<Form>` takes any [Standard Schema](https://standardschema.dev) — Zod, Valibot, ArkType, or anything else implementing it. There is no adapter per library and no import of one: the schema is asked to validate itself. ```tsx title="src/components/PostForm.tsx" "use client"; import { z } from 'zod'; import { Form } from '@rsc-kit/core/Form'; import { createPost } from '../actions'; const schema = z.object({ title: z.string().min(3, 'Too short'), body: z.string().min(10, 'Too short'), }); export function PostForm() { return ( <Form action={createPost} schema={schema}> {({ error, pending }) => ( <> <input name="title" /> {error('title') && <p className="error">{error('title')}</p>} <textarea name="body" /> {error('body') && <p className="error">{error('body')}</p>} <button disabled={pending}>Create</button> </> )} </Form> ); } ``` A failure fills the same `errors` you were already rendering and the action is never called — so a mistake costs no round trip, and an optimistic update never shows a row the server was going to refuse. Async schemas are awaited, so a uniqueness check works the same way. Field names follow the schema's paths: a nested field is `address.city`, and an issue belonging to no field — a cross-field rule like "these must match" — is under the empty string, so it has somewhere to be shown rather than being dropped. <Aside type="danger" title="A schema here is a courtesy, not a control"> It runs in the browser. The action is a public endpoint reachable without this form — with `curl`, with the wrong values, in any order — so the server still has to check. See [Authorization](/guides/authorization). Use the same schema on both sides if you like; the point is that the one running on the server is the one that decides. </Aside> ### In a JavaScript action The same schema, on the side that decides. An action **returns** its failure rather than throwing one, so one line turns that into the error the form already understands: ```ts title="src/actions.ts" 'use server' import { schema } from './schema'; export async function createPost(title: string, body: string) { const parsed = await schema['~standard'].validate({ title, body }); if (parsed.issues) { return { ok: false as const, issues: parsed.issues }; } return { ok: true as const, post: await savePost(parsed.value) }; } ``` ```tsx title="src/components/PostForm.tsx" import { ServerValidationError } from '@rsc-kit/core/errors'; import { issuesToErrors } from '@rsc-kit/core/standardSchema'; async function submit(formData: FormData) { const result = await createPost( formData.get('title') as string, formData.get('body') as string, ); if (!result.ok) throw new ServerValidationError('Validation failed', issuesToErrors(result.issues)); return result.post; } ``` `issuesToErrors` is the same mapping the client-side check uses, so the field names match whichever side produced them. ## ServerValidationError | Member | Type | What it holds | | --- | --- | --- | | `errors` | `Record<string, string[]>` | Field name to messages. | | `message` | `string` | A summary, for when there is nowhere to put field errors. |