place

Forms & actions

Two patterns. Page-attached on: handlers when the form belongs to a page; standalone action() + <Form> when it travels.

Page-attached

src/pages/subscribe.page.tsxts
// 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>

ts
// 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.

Auto-CSRF is on
Every action requires a CSRF token. If the page's 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.

ts
// 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.

ts
// 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.
No bundled DSL
We don't ship a 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-originOrigin / Referer header enforcement.
  • Body size — configurable cap; default 1 MB.
  • Prototype pollution — JSON parser strips __proto__ and constructor keys.

See also