docs / Runtime
Forms & Validation
The Form and Field components, validation rules, and server actions with defineAction, validateActionInput and issuesToFieldMap.
Vesk's `<Form>` and `<Field>` components render `novalidate` HTML with validation error slots and wire client-side submit handling: field rules are validated first, then the form either runs `onSubmit`, posts to a server action descriptor, or does a plain `fetch` to `action`. Field/server errors appear inline via each field's `[data-vsk-error]` slot.
Form component
import { Form, Field, required, email, minLength } from '@vesk/runtime';
component SignupForm() {
<Form action="/api/signup" method="POST">
<Field name="name" label="Name" rules={[required('Name is required')]} />
<Field name="email" label="Email" rules={[required(), email('Invalid email')]} />
<Field name="password" label="Password" rules={[minLength(8, 'Min 8 chars')]} />
<button type="submit">Sign up</button>
</Form>
}
| Prop | Type | Description |
|---|---|---|
| action | string | Record<string, unknown> | Submit URL, or a server action descriptor (defineAction result / ActionStub) |
| method | string | HTTP method, defaults to 'POST' |
| onSubmit | (data: Record<string, unknown>, form: HTMLFormElement) => void | Promise<void> | Runs with the validated data in place of any fetch |
| onError | (err: unknown) => void | Called when the submission throws |
| onSuccess | (res: Response) => void | Called after a successful fetch |
| class / style | string | Forwarded to the <form> element |
| children | string | Node | Fields and submit control |
During SSR the form renders `<form action=... method=... novalidate>` with a `[data-vsk-field]` wrapper per field. On the client, submit is intercepted: field rules are validated, submitting states are announced through `vsk-loading`/`vsk-submitting` and outcomes through `vsk-success`/`vsk-error` custom events. When `onSubmit` is set it runs instead of a fetch. A plain string `action` posts `FormData` with `fetch`; an action descriptor posts JSON and maps `issues` back onto fields.
Field component
import { Form, Field, required, email, minLength } from '@vesk/runtime';
component SignupForm() {
<Form action="/api/signup" method="POST">
<Field name="name" label="Name" rules={[required('Name is required')]} />
<Field name="email" label="Email" rules={[required(), email('Invalid email')]} />
<Field name="password" label="Password" rules={[minLength(8, 'Min 8 chars')]} />
<button type="submit">Sign up</button>
</Form>
}
| Prop | Type | Description |
|---|---|---|
| name | string | Field key in the submitted data and issue mapping |
| label | string | Rendered as a <label> above the field |
| rules | ValidationRule[] | Rules validated in order; first failure wins |
| errorClass | string | Class applied to the error slot when a message is showing |
| class / style | string | Forwarded to the wrapping element |
| children | string | Node | Your input element; extra attrs are forwarded to the wrapper |
Each field renders a `<div data-vsk-field="name">` wrapper with a `<div data-vsk-error>` slot. `errorClass` lands on the error slot whenever a message is displayed, and server-side validation errors from a re-render (e.g. a failed server action) are pre-rendered into the slot through the SSR error channel.
Validation rules
- `required(msg?)` — non-null and not `''`. Default message: "This field is required".
- `email(msg?)` — empty ok, otherwise matches an email pattern. Default: "Invalid email address".
- `minLength(n, msg?)` — empty ok or length >= n. Default: "Must be at least n characters".
- `maxLength(n, msg?)` — empty ok or length <= n. Default: "Must be at most n characters".
- `pattern(re, msg?)` — empty ok or matches the RegExp. Default: "Invalid format".
- `custom(fn, msg?)` — empty ok or `fn(value)` returns true. Default: "Invalid value".
Every rule builder returns `{ validate: (v: unknown) => boolean, message: string }` so you can assemble your own `ValidationRule`s too. Rules run in order and the first failure wins; a Field's `rules` prop and the action descriptor's per-field schema rules are combined on submit.
Server actions
import { defineAction, required, email, minLength } from '@vesk/runtime';
export const signup = defineAction({
input: {
name: required('Name required'),
email: [required(), email()],
password: minLength(8, 'Min 8 characters'),
},
async execute(input, ctx) {
const user = await createUser(input);
ctx.redirect('/dashboard');
return { user };
},
});
`defineAction(config)` returns an `ActionDefinition` `{ id, url: '/_vesk/action/<id>', input, execute }`. Inside a `.vsk` file the compiler rewrites the call to `defineAction("<stable-id>", config)` so the client stub and the server registration share one id; called directly (`defineAction(id, config)`), the id falls back to a hash of the `execute` function source.
| ActionContext member | Description |
|---|---|
| request | The incoming platform `Request` |
| params | Route params as `Record<string, string>` |
| url | The request URL string |
| headers() | () => Map<string, string> of response headers |
| cookies() | () => Record<string, string> of request cookies |
| locals() | () => Record<string, unknown> per-request data |
| redirect(url, status?) | Return a redirect `Response` (default status 307) |
import { Form, Field, required, email, minLength } from '@vesk/runtime';
component SignupForm() {
<Form action="/api/signup" method="POST">
<Field name="name" label="Name" rules={[required('Name is required')]} />
<Field name="email" label="Email" rules={[required(), email('Invalid email')]} />
<Field name="password" label="Password" rules={[minLength(8, 'Min 8 chars')]} />
<button type="submit">Sign up</button>
</Form>
}
When `action` is an action object (`__veskAction` or a definition with `url`), the client posts `{ 'Content-Type': 'application/json' }` with the collected `FormData` as a JSON object. A response of `{ ok: false, issues: [{ field, message }] }` maps each issue onto its field's error slot (first message per field wins) and re-displays them without leaving the page; any other non-ok response throws into `onError`. A server-side validation failure also re-renders the page with issues converted through `issuesToFieldMap`. `onSubmit(data, form)` runs instead of the fetch when provided.
Manual validation
import { validateActionInput, issuesToFieldMap } from '@vesk/runtime';
const issues = validateActionInput(signup, inputData);
// [{ field: 'email', message: 'Invalid email address' }, ...]
const fieldErrors = issuesToFieldMap(issues);
// { email: 'Invalid email address', password: 'Min 8 characters' }
`validateActionInput(actionDef, inputData)` runs each field's schema rules and returns `ActionIssue[]` (`{ field, message }`), stopping at the first failed rule per field. `issuesToFieldMap(issues)` collapses them into a per-field map you can feed straight back into a re-render — the same map the SSR error channel uses to pre-fill field errors.
Field `rules` and the action descriptor's `input` schema both apply: the descriptor schema is read from `action.input` and merged with each Field's `rules` on submit. Keep the shape in sync — fields not present in the descriptor still validate against their own `rules`.