Forms + mutations
Real form state, schema validation, server-side actions, automatic loader revalidation, and a no-JS path that works without the framework ever loading on the client.
The mental model
Forms in rogue are a marriage of two things you'd otherwise wire by hand: a client-side form-state library (think react-hook-form / Formik) and a server-side mutation endpoint (a POST route). The framework provides both sides + the wire between them.
On the page module, you export an actions map. Each
action is a pair: a schema (validation rules used
on both client and server) and a run function
(the mutation itself, server-only). The schema lets the client do
instant feedback as the user types, and the server enforces the
same rules before it touches data — so you write the rules once.
In the component, useForm(actions.signup) returns an
API you spread onto a regular and its
inputs. Submitting goes through fetch() when JS is
loaded, and through a normal HTML form POST when it isn't —
the server-side path is the same code either way,
so the no-JS path keeps working without extra effort from you.
The shape
Putting both halves together looks like this:
// src/pages/signup.tsx
import type { ActionArgs } from './signup.types'
import { useForm, defineActions, redirect, invalid, formError } from '@jjordy/rogue/forms'
export const actions = defineActions({
signup: {
schema: {
email: { type: 'email', required: 'Email is required' },
password: { type: 'password', required: true, minLength: 8 },
confirm: {
required: true,
validate: (v, all) => v === all.password ? null : 'must match',
},
},
async run({ data, request }: ActionArgs<'signup'>) {
if (await isTaken(data.email)) return invalid({ email: 'already registered' })
await createUser(data)
return redirect('/welcome')
},
},
})
export default function SignupPage() {
const form = useForm(actions.signup)
return (
<form {...form.props}>
{form.formError() && <div class="alert">{form.formError()}div>}
<input {...form.field('email')} />
<span class="err">{form.errors().email}span>
<input {...form.field('password')} />
<span class="err">{form.errors().password}span>
<input {...form.field('confirm')} />
<span class="err">{form.errors().confirm}span>
<button disabled={form.submitting()}>
{form.submitting() ? 'Creating…' : 'Sign up'}
button>
form>
)
}
What's automatic
-
Form attributes:
form.propssetsmethod="POST",action="?_action=signup", andencType="multipart/form-data". The form submits natively if JS hasn't loaded. -
Input bindings:
form.field('email')returnsname,value,onInput,onBlur, andaria-invalid— spread onto an,, or. -
Submit lock: a second submit while one is in flight
is rejected. Latest-wins for programmatic
form.submit()calls. -
Validation cadence: errors hide until first submit.
After that, each field validates on every keystroke so the user
gets live feedback as they fix things. (Configurable: pass
{ mode: 'change' | 'blur' | 'submit' | 'submit-then-live' | 'touched' }touseForm().) -
Auto-revalidation: after a successful
run()that doesn't redirect, the server re-runs the page'sloader()and pushes the fresh data back. The page re-renders with new content. No navigation, no flash.
Validation
A schema describes the rules for each field. The
framework accepts two shapes and detects which you're using
automatically — there's no setup or configuration switch.
Built-in field map (zero deps)
The simplest schema is a plain object: one entry per field, listing
its constraints. It covers the common cases (required, length,
pattern, type) and gives you an escape hatch via a custom
validate function for everything else.
{
field: {
type?: 'text' | 'email' | 'url' | 'number' | 'tel' | 'password' | 'file'
required?: boolean | string // string is a custom error message
minLength?: number; maxLength?: number
min?: number; max?: number
pattern?: RegExp | { value: RegExp; message: string }
validate?(value, allValues): string | null | Promise<string | null>
}
}
The validate escape hatch handles cross-field rules
(password confirmation, mutually exclusive inputs) and anything else
the built-in constraints don't cover. Returns the error message, or
null for success.
Any Standard Schema library
Zod, Valibot, ArkType — anything that conforms to standardschema.dev works without configuration:
import { z } from 'zod'
export const actions = defineActions({
signup: {
schema: z.object({
email: z.string().email(),
password: z.string().min(8).regex(/[A-Z]/, 'needs uppercase'),
}),
async run({ data }) { /* data: { email: string, password: string } */ },
},
})
Neither library is bundled or required. The framework's adapter is
~50 lines and detects schema flavor via safeParseAsync.
Action returns — telling the framework what happened
run() isn't expected to set status codes or send
responses. Instead, it communicates outcome by what it returns,
and the framework translates that into the right HTTP response
(and client-side behavior) for you. The four shapes:
-
return(undefined) — success. Loader re-runs, page re-renders with fresh data. -
return redirect(path)— success, navigate. The current page's loader doesn't re-run (you're leaving). With JS: SPA navigate. Without JS: 303 redirect. -
return invalid({ field: message })— business-logic error mapped toform.errors().field. Schema couldn't catch it (e.g., uniqueness constraints, server-side state). -
return formError('message')— error not tied to a field (session expired, rate limited). Shown viaform.formError(). -
throw err— unrecoverable, routes to_error.jsx.
Progressive enhancement
A with a method="POST" and an
action attribute is one of the few platform features
that work whether JavaScript loaded or not — the browser submits
natively, the server returns a response, the browser navigates to
it. rogue's form helpers preserve that property.
The framework chooses the JS vs no-JS path by inspecting the
Accept header on the action POST. Either path runs
the same server-side run(); only the response
encoding differs.
With JS
useForm() intercepts submit, posts via
fetch(), gets JSON, patches reactive state in place.
No navigation, no scroll. Optimistic UI is a follow-up.
Without JS
Native form POST. Server runs the same action, re-renders the
page with errors embedded in __JSX_WC_DATA__.form.
User sees errors immediately; if JS loads later, useForm
seamlessly picks up the embedded state.
Types
The rogueRouter plugin emits a typed
./ module alongside each page. Each
export — Params, LoaderArgs,
ActionArgs — is derived from the file path and
the action's schema:
// src/pages/post/[id].tsx
import type { LoaderArgs, ActionArgs } from './[id].types'
export const loader = async ({ params }: LoaderArgs) => {
// params.id autocompletes — typed from the file path's [id] bracket
}
export const actions = defineActions({
reply: {
schema: { text: { required: true, maxLength: 5000 } },
async run({ data, params }: ActionArgs<'reply'>) {
// data: { text: string }
// params: { id: string }
},
},
})
Add this to tsconfig.json so the generated types
resolve:
{
"compilerOptions": {
"rootDirs": [".", "./.rogue/types"]
}
}
The plugin auto-adds .rogue/ to
.gitignore on first run. The types regenerate on every
page-file change.
useForm() return value
interface FormApi<T> {
props // spread on
field(name) // spread on /
errors() // reactive: { fieldName: errorMessage }
formError() // reactive: form-level error message or null
submitting() // reactive: true while POST in flight
values() // reactive: current field values
touched() // reactive: which fields have been blurred
validating() // reactive: per-field async validators in flight
submit() // imperative submit (same path as form.props.onSubmit)
reset() // back to initial values; clears errors + touched
}
Server wiring
In dev mode, rogueSsr() (the Vite plugin) already
handles action POSTs — you don't need to write any wiring code.
For production deployment you call handleAction()
yourself alongside render(). The decision is just
"does the URL have a ?_action=… query and a POST
method? then it's an action":
import { render, handleAction } from '@jjordy/rogue/server'
if (req.method === 'POST' && new URL(req.url, 'http://x').searchParams.has('_action')) {
const out = await handleAction(req.url, vite, request)
res.statusCode = out.status
for (const [k, v] of Object.entries(out.headers ?? {})) res.setHeader(k, v)
res.end(out.body ?? '')
} else {
const { html, status } = await render(req.url, vite, { request })
res.statusCode = status
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.end(html)
}