@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
Markdown
# 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. |