Forms & actions
Two patterns. Page-attached on: handlers when the form belongs to a page; standalone action() + <Form> when it travels.
Page-attached
// Co-located on a page — the natural shape for "form belongs to page".
const subscribe = page('/subscribe', {
on: {
submit: async (input: { email: string }) => {
await emailList.add(input.email)
return { ok: true }
},
},
view: () => (
<form onSubmit={(e) => {
e.preventDefault()
const fd = new FormData(e.target as HTMLFormElement)
void subscribe.submit({ email: String(fd.get('email')) })
}}>
<input name="email" type="email" required />
<button>Subscribe</button>
</form>
),
})
Each entry in on: auto-registers at POST {path}/_action/{key}. The caller subscribe.submit({...}) is typed; the URL is visible.
action() + <Form>
// Standalone action — when the form lives on multiple pages or
// outside a page entirely (e.g. a global newsletter form in the footer).
import { action, Form, shape } from '@place/component'
export const subscribe = action({
path: 'POST /api/subscribe',
input: shape({ email: 'string' }),
fn: async ({ email }) => {
await emailList.add(email)
return { ok: true }
},
})
// Anywhere:
<Form action={subscribe}>
<input name="email" type="email" required />
<button>Subscribe</button>
</Form>
<Form> works with JS enabled (fetch + JSON; typed return) and disabled (form-encoded POST; the action handles both). The full security pipeline applies either way.
load() returns a csrf field, the framework injects it as a meta tag and <Form> + action.call() pick it up automatically. No per-form wiring.Validation
fromStandard(schema) adapts any Standard Schema v1 validator (Zod 3.24+, Valibot 0.36+, ArkType, Effect Schema, …) into an ActionSchema<T>. The framework ships no validation dep; pick your library, the inferred output type flows.
// Bring your own validator — Zod 3.24+, Valibot 0.36+, ArkType,
// Effect Schema, or the built-in shape(). All Standard-Schema-
// compliant libraries plug in the same way via fromStandard().
import { z } from 'zod'
import { action, fromStandard } from '@place/component'
const SubscribeIn = z.object({
email: z.string().email(),
source: z.enum(['footer', 'modal', 'inline']).default('footer'),
})
export const subscribe = action({
path: 'POST /api/subscribe',
input: fromStandard(SubscribeIn),
fn: async (input) => {
// input is typed by Zod's inferred output: { email: string; source: '...' }
await emailList.add(input.email, input.source)
return { ok: true }
},
})
Field-level errors
On validation failure, fromStandard throws ActionError(400, 'Validation failed', { fields }). The fields map is keyed by dotted path (email, profile.age, items.0.name) → message. Narrow the payload via isValidationFailure and route each path to its <Field error={...}> state cell.
// Field-level errors via fromStandard + isValidationFailure +
// <Field error={...}>. The validator's per-field messages route to
// the matching <Field>'s error state cell automatically.
import { state } from '@place/reactivity'
import { ActionError, Form, isValidationFailure } from '@place/component'
import { Field, Input, Button } from '@place/design'
import { signup } from './shared.action'
// One state cell per field. Apps that want sugar can compose a
// formErrors() helper that returns a Record<string, State<string>>.
const emailErr = state('')
const ageErr = state('')
<Form
action={signup}
onSuccess={() => {
emailErr.set('')
ageErr.set('')
}}
onError={(e) => {
if (e instanceof ActionError && isValidationFailure(e.payload)) {
emailErr.set(e.payload.fields.email ?? '')
ageErr.set(e.payload.fields.age ?? '')
}
}}
>
<Field label="Email" error={() => emailErr()}>
<Input name="email" type="email" required />
</Field>
<Field label="Age" error={() => ageErr()}>
<Input name="age" type="number" required />
</Field>
<Button type="submit">Sign up</Button>
</Form>
// On server failure → ActionError(400, "Validation failed", { fields })
// is thrown by fromStandard. onError narrows via isValidationFailure
// and routes each path to its <Field error={...}> state cell. No
// per-field plumbing on the server side; the validator's messages
// flow through structurally.
definePolicy() / form-state DSL on top. Standard Schema is the contract; apps wire whichever validator they already use. See ADR 0045 for the rationale.Security pipeline
- CSRF — token + double-submit cookie, validated before the handler runs.
- Same-origin —
Origin/Refererheader enforcement. - Body size — configurable cap; default 1 MB.
- Prototype pollution — JSON parser strips
__proto__andconstructorkeys.