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

app/components/SignupForm.vsk
										
											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

app/components/SignupForm.vsk
										
											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

app/actions/signup.ts
									
										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)
app/components/SignupForm.vsk
										
											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

app/actions/signup.ts
									
										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`.