Docs

Forms and actions

Build and validate HTML forms, handle submissions with Axum, and attach typed actions.

Open Markdown

Proa keeps server mutations explicit: the browser submits a native HTML form, and an Axum handler validates and writes the data. This page covers rendering the form, validation with Wellformed, typed client actions, and choosing a boundary.

Rendering a native form

Use real form controls whenever a browser submit is enough:

src/pages/contact.rs
use proa_macros::html_sync;

html_sync! {
    <form method="post" action="/contact" class="grid gap-4">
        <label for="email">"Email"</label>
        <input id="email" name="email" type="email" required autocomplete="email" />

        <label for="message">"Message"</label>
        <textarea id="message" name="message" required minlength="10"></textarea>

        <button type="submit">"Send"</button>
    </form>
}

This renders useful HTML before any JavaScript loads. The browser owns focus, keyboard submission, autofill, and constraint validation. Those constraints improve the experience, but the server must still validate every submission.

Form controls

Start with native controls:

ComponentNative element
Text input<input type="text">, <input type="email">, <input type="password">
Text area<textarea>
Select<select>
Checkbox<input type="checkbox">
Radio group<input type="radio">
Button<button>
Field structure<label>, <fieldset>, <legend>, help text, error text

Prefer native names and values for any data that must reach the server. That keeps the submission inspectable and works without JavaScript.

Markup is half the story. The POST needs a server-side validation boundary.

Validating with Wellformed

Use Wellformed to keep normalization, validation rules, generated Rust values, and field errors in one schema:

Terminal
cargo add wellformed wellformed-macros wellformed-validate serde_json
cargo add serde --features derive

Check the serialized schema into the application. This example trims both fields, normalizes the email, and gives every failure a stable code and message:

schemas/contact.json
{
  "version": "1.0.0",
  "id": "contact",
  "title": "Contact",
  "root": {
    "type": "object",
    "unknown_keys": "strict",
    "properties": {
      "email": {
        "type": "string",
        "label": "Email",
        "required": true,
        "transforms": [{ "fn": "trim" }, { "fn": "lower" }],
        "constraints": [
          {
            "pred": { "type": "call", "name": "is_email" },
            "error": {
              "code": "INVALID_EMAIL",
              "message": "Enter a valid email address"
            }
          }
        ]
      },
      "message": {
        "type": "string",
        "label": "Message",
        "required": true,
        "transforms": [{ "fn": "trim" }],
        "constraints": [
          {
            "pred": { "type": "min_len", "len": 10 },
            "error": {
              "code": "MESSAGE_TOO_SHORT",
              "message": "Use at least 10 characters"
            }
          }
        ]
      }
    }
  }
}

Generate the typed form facade at compile time:

src/forms/contact.rs
use wellformed_macros::form_schema;

form_schema!(pub mod contact = "schemas/contact.json");

Extract the native form into an untyped map so Wellformed sees the untrusted payload before Serde constructs the generated Rust type:

src/routes/contact.rs
use std::collections::HashMap;

use axum::{
    extract::Form,
    http::StatusCode,
    response::{IntoResponse, Redirect, Response},
};
use proa_framework_axum::RouteResponse;

use crate::{forms::contact, pages::contact::ContactPage};

pub async fn submit_contact(Form(fields): Form<HashMap<String, String>>) -> Response {
    let submitted = serde_json::to_value(fields)
        .expect("string form fields serialize to JSON");

    let values = match contact::validate_form(submitted) {
        Ok(values) => values,
        Err(errors) => {
            let mut response = RouteResponse::ssr(ContactPage {
                form: contact::state_with_errors(errors),
            })
            .into_response();
            *response.status_mut() = StatusCode::UNPROCESSABLE_ENTITY;
            return response;
        }
    };

    save_contact(values).await;

    Redirect::to("/contact/thanks").into_response()
}

values is contact::Values, generated from the schema after transforms and validation succeed. The redirect follows the post/redirect/get pattern so refreshing the success page does not resubmit the mutation.

Register the route beside the page route:

src/main.rs
use axum::routing::{get, post};
use proa_framework_axum::Route;

Route::page("/", "Contact", get(contact_page));
Route::endpoint("/contact", post(submit_contact));

Use Route::endpoint for non-page POST handlers unless the endpoint also needs page metadata. Route handlers covers JSON endpoints, body limits, shared state, webhooks, and endpoint tests.

Re-rendering field errors

The generated form state keeps the submitted values and maps validation paths to field accessors:

src/pages/contact.rs
use crate::forms::contact;

pub struct ContactPage {
    pub form: contact::State,
}

Use those accessors to restore values and connect each error to its control:

src/pages/contact.rs
let email = contact::field_email(&self.form);

html_sync! {
    <input
        id="email"
        name="email"
        type="email"
        required
        autocomplete="email"
        value={email.value_str()}
        aria-invalid={email.invalid()}
        aria-describedby={email.invalid().then_some(email.error_id())}
    />
    {if let Some(error) = email.error_message() {
        html_sync! {
            <p id={email.error_id()} role="alert">{error}</p>
        }
    }}
}

Use the same pattern for the message field. contact::state_with_errors preserves all submitted values, while field_email supplies the stable error id, invalid state, and message needed by accessible HTML.

Server mutation checklist

Before shipping a form:

FrameworkBuilder enables cross-origin protection for unsafe browser requests by default. Keep it enabled unless you have a specific trusted-origin deployment requirement. Deployment covers production hardening.

That covers everything the server owns. Browser-only behavior needs a different mechanism.

Adding typed client actions

Actions are not a hidden RPC layer. They annotate rendered HTML so client code can dispatch typed handlers. Server writes still happen through normal HTTP routes.

Use #[derive(Action)] when client-side code needs stable, typed action names:

src/actions/product.rs
use proa_actions::{Action, ActionHandler};

#[derive(Debug, Clone, Action)]
#[action(scope = "product_card")]
pub enum ProductAction {
    Favorite,
    AddToCart { sku: String },
}

The derive emits constants and conversion helpers:

src/actions/product.rs
assert_eq!(
    ProductAction::FAVORITE,
    "product_card.ProductAction.Favorite",
);

let data = ProductAction::AddToCart {
    sku: "sku_1".to_string(),
}
.to_action_data();

let restored = ProductAction::from_action_data(data)?;

Use a unique scope per page, island, or component family. The scope prevents action-name collisions in generated client code.

Attaching actions to elements

Render static action identifiers as data attributes:

src/components/product_card.rs
html_sync! {
    <button type="button" data-onclick={ProductAction::FAVORITE}>
        "Favorite"
    </button>
}

This renders a data-onclick attribute holding the action name. The generated action runtime can delegate browser events from those attributes.

When the handler needs parameters, render explicit data-param-* attributes near the action:

src/components/product_card.rs
html_sync! {
    <button
        type="button"
        data-onclick={ProductAction::ADD_TO_CART}
        data-param-sku={self.sku}
    >
        "Add to cart"
    </button>
}

Parameter names must be safe attribute-name suffixes: ASCII letters, digits, underscores, and hyphens.

Generating the client runtime

proa_actions::codegen can generate three TypeScript artifacts from action metadata:

ArtifactPurpose
Contractaction constants, parameter types, and handler interfaces
Runtimedelegated event listeners for data-onclick, data-onblur, data-onfocus, and related event attributes
Handler stubuser-owned file where you implement browser behavior

The runtime parses data-param-* attributes as strings and passes them to the registered handler. Convert or validate types in the handler when needed.

Server routes and client actions overlap, so pick the boundary deliberately.

Choosing a boundary

Start from the need:

NeedUse
Submit data to the serverNative <form method="post"> plus an Axum handler.
Validate and persist dataValidate with a Wellformed schema, then persist the generated values.
Trigger client-only behaviorproa_actions identifiers and data-on* attributes.
Keep form UI reactiversjs controlled components or ReactiveAttr bindings.

Then match the specific situation:

SituationRecommended boundary
Contact form, checkout, account updateNative form plus Axum POST handler.
Opening a dialog or toggling local UIClient action binding or small controller.
Field value drives live browser UIControlled rsjs component.
Data changes server stateServer route, not a client-only action.
Static button action with no paramsdata-onclick={...} on a button.
Dynamic action paramsExplicit data-param-* attributes or a native form field.

Next steps

Search

Type at least 2 characters