Forms (architect-form)
Typed, validated form state for Dioxus — the effect-form analog, building on architect-atom and submitting through vox.
architect-form (features/form, re-exported as the architect::form
module under the form feature — architect::form::Field, architect::form::TextField, …) is the form-shaped sibling of
architect-atom, modelled on
effect-form. It’s the
controlled improvement over the baseline Dioxus form.
The baseline it improves on#
Dioxus’s built-in form is uncontrolled: inputs carry name attributes
and you pull the values off the FormEvent on submit —
form {
onsubmit: move |evt: FormEvent| {
evt.prevent_default();
let values: LoginForm = evt.parsed_values().unwrap(); // name-keyed deserialize
// …submit values…
},
input { name: "username" }
input { name: "password" }
}
That’s fine for a throwaway form, but there’s no per-field validation, no
error/touched/dirty state, no “disable submit while invalid”, and (in the
fullstack docs) it hands off to a #[post] server function —
which architect forbids: all I/O goes through
vox. architect-form keeps evt.parsed_values()’s spirit (it’s a core
FormEvent method, fine in CSR) but makes the form controlled and
submits through the vox client.
Forms are derived from the entity#
This is the part effect-form can’t do: Rust’s “schema” is the type
itself, and the Entity derive already owns it. Add form to the derive
and the typed form layer is emitted over the Create/Update payloads:
#[architect(table_name = "examples", repo, store, form)]
pub struct Example {
#[architect(filterable, sortable, fulltext)]
pub name: String, // form: required
#[architect(filterable, fulltext, form(optional))]
pub description: String, // form: optional
…
}
generates (gated on the proto crate’s form = ["architect/form"]):
ExampleCreateFields/use_example_create_fields()— one validatedFieldper Create-payload field.Stringfields are required (Title-Case label, override withform(label = "…")) unless markedform(optional); any other type validates throughvalidate::parse::<T>(FromStr).ExampleUpdateFields/use_example_update_fields(&example)— the same fields, seeded from the current row.submit() -> Option<ExampleCreate>(resp.ExampleUpdate) — validates every field (revealing errors) and returns the typed wire payload, the exact struct the derived mutations take. Plusis_dirty()/reset().
With the generic TextField component (label + controlled input +
inline error over any Field<T>), a whole create form is layout only:
let fields = use_example_create_fields();
rsx! {
form {
onsubmit: move |evt| {
evt.prevent_default();
if let Some(input) = fields.submit() { // -> ExampleCreate
on_submit.call(input); // → mutations.create(input)
fields.reset();
}
},
TextField { field: fields.name, label: "Name" }
TextField { field: fields.description, label: "Description", placeholder: "optional" }
button { class: "btn primary", r#type: "submit", "Add example" }
}
}
The pipeline is typed end-to-end: entity → form fields → validated payload → optimistic mutation → reconcile — one source of truth, zero hand-written field plumbing.
Fields (the primitive underneath)#
A Field<T> owns a string-encoded value
plus a parser that validates and decodes into a typed T — the role
effect-form gives an Effect Schema. It tracks error, touched, and
dirty, and re-validates live once the field has been touched:
use architect::form::{use_field, validate};
let name = use_field("", validate::required("Name")); // -> Field<String>
let desc = use_field("", validate::optional());
let age = use_field("", |s: &str| s.parse::<u8>().map_err(|_| "must be a number".into()));
let name_value = name.value();
let name_error = name.error(); // Some(..) only after touch / submit attempt
rsx! {
input {
value: "{name_value}",
oninput: move |e| name.set(e.value()), // live re-validate once touched
onblur: move |_| name.blur(), // mark touched + validate
}
if let Some(err) = name_error {
span { class: "field-error", "{err}" }
}
}
Validator combinators live in validate:
required, optional, min_length, max_length, email, and and to
chain them. Any Fn(&str) -> Result<T, String> works, so a field can
decode to any type (u8, a domain enum, …).
Hand-write fields only for forms that don’t mirror a payload (login,
search, settings); payload-shaped forms come from the derive. The worked
references: the derive in crates/architect-derive (the form
attribute) and the primitives in features/form.
Field components#
The rendering half ships ready-made — label + control + inline error over
any Field:
TextField/TextArea— controlled text inputs (generic over the decoded type; aField<u32>renders like aField<String>, the parser is the difference).SelectField—<select>over(value, display)options; pair withvalidate::parse::<YourEnum>for typed enums.Checkbox— over aField<bool>(seed with"false",validate::parse::<bool>).
Apps with a design system write their own equivalents — the Field
surface (value / set / blur / error) is the contract.
Submitting#
There is one way to submit: the (usually derived) fields produce the typed wire payload, and the derived optimistic mutations take it —
if let Some(input) = fields.submit() { // -> ExampleCreate, errors revealed
mutations.create(input); // optimistic row + reconcile/rollback
}
Pending/error state lives on the mutation handle (is_pending,
create_error), failures report to the app’s Notifications, and the
row appears instantly — there’s no separate form-submit machinery.
Enforced: validator combinators are unit-tested in
features/form; the example’s create form is exercised byexample-ui’stests/components.rs.
Linked from
- ClientsThe generated vox and HTTP clients, connecting and the connection lifecycle, the offline queue and optimistic state, forms.
- Atom and formsarchitect-atom: optimistic, stale-while-revalidate client state, a keyed store with temp ids, mutations that patch then reconcile, device rows and an offline call queue; architect-form: typed, validated fields.
- Idioms & enforcementThe bearcove/vox + Dioxus conventions architect follows, and what keeps them honest.