rogue

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.props sets method="POST", action="?_action=signup", and encType="multipart/form-data". The form submits natively if JS hasn't loaded.
  • Input bindings: form.field('email') returns name, value, onInput, onBlur, and aria-invalid — spread onto an , , or