Forms
riposte-forms is a form layer in the shape of react-hook-form: fields are uncontrolled — the DOM owns the text and the form reads it through a ref — so typing…
riposte-forms is a form layer in the shape of react-hook-form: fields are uncontrolled — the DOM owns the text and the form reads it through a ref — so typing never re-renders the component tree. Only the parts that read form state (errors, validity) wake up, and only when their slice changes. It’s a separate artifact built on the core’s public API, usable on its own or under salle.
Add the dependency (it pulls in the core transitively):
libraryDependencies += "io.github.edadma" %%% "riposte-forms" % "0.2.0"
import io.github.edadma.riposte.forms.*
A form
useForm returns a handle. register wires an uncontrolled element by spreading mods onto
it (see spreading a bundle of mods);
handleSubmit validates every field then runs your callback with the values:
val Signup = view {
val f = useForm(defaultValues = Map("email" -> ""))
form(onSubmit := f.handleSubmit(values => save(values)))(
input(typ := "email", f.register("email", Rules(required = true))*),
f.formState.errors.get("email").map(e => span(cls := "error", e.message)),
button(typ := "submit", "Sign up"),
)
}
f.formState is the live snapshot — read errors, isDirty, isValid, isValidating,
isSubmitting, isSubmitted, submitCount, touchedFields, dirtyFields in the body and
the component re-renders exactly when that slice changes.
For a submit that awaits — a server POST — use handleSubmitAsync(values => api.save(values))
where the handler returns a Future[Unit]; formState.isSubmitting stays true across both
validation and the handler, so a spinner or disabled button bound to it covers the whole
round-trip.
Validation rules
Rules mirrors react-hook-form’s per-field rule object — each rule is opt-in and the first
failure wins:
f.register("password", Rules(
required = true,
minLength = Some(8),
pattern = Some("""\d""".r),
validate = Seq(v => Option.when(v == "password")("Too obvious")),
messages = Messages(minLength = "Use at least %s characters"),
))
required, minLength/maxLength (string length), min/max (numeric), pattern (a
Regex), and validate (custom predicates returning Some(message) to reject). A failure
is a FieldError(kind, message) — kind names the rule that rejected so a UI can branch on
it. Messages overrides the default text; %s is filled with the rule’s bound.
When fields validate is set by mode (before the first submit) and reValidateMode
(after it), each a ValidationMode: OnSubmit (default), OnBlur, OnChange, OnTouched,
or All.
Schema validation
To drive validation from a whole-form schema (zod/yup-style, or a hand-written check)
instead of per-field Rules, pass a resolver — it takes the current values and returns the
errors keyed by field (an empty map means valid):
val f = useForm(resolver = Some { values =>
if values.getOrElse("email", "") == "" then Map("email" -> FieldError("schema", "Required"))
else Map.empty
})
asyncResolver is the awaiting counterpart (a server check, a uniqueness query) — while it
runs, formState.isValidating is true. Configure one or the other, not both.
The imperative API
The handle also exposes:
setValue(field, value, shouldValidate)/getValue[T](field)/getValues— write and read field values (writing reflects back into the live element).trigger(field)/trigger()— run validation on demand, returning validity;triggerAsync(field)/triggerAsync()return aFuture[Boolean]for an async resolver.setError(field, err)/clearErrors(field)/clearErrors()— drive errors by hand, e.g. from server-side validation.reset(values)/reset()— reset to fresh values (or the original defaults), clearing errors/touched/submit state.
Watching values
f.getValues is a one-shot, non-reactive read. To re-render as a value changes, watch it —
the calling component wakes only for the field(s) it watches:
val agreed = f.watch[Boolean]("agree") // one field, on the handle
val email = useWatch[String](f.control, "email")
val all = useWatchAll(f.control) // every value
val some = useWatchFields(f.control, Seq("first", "last"))
Controlled components
register wires uncontrolled DOM elements. For a component that takes a value and emits
changes through a callback — salle’s Input, Select, Checkbox — use Controller, which
subscribes to the field’s value and error and hands them to your render function:
Controller("country", f.control, Rules(required = true)) { a =>
Select(
value = a.field.value.asInstanceOf[String],
onChange = v => a.field.onChange(v),
onBlur = _ => a.field.onBlur(),
invalid = a.fieldState.invalid,
)
}
The render function receives field (name/value/onChange/onBlur), fieldState
(error/isTouched/isDirty/invalid), and the whole formState. The store owns the
value, so it stays live and the Controller re-renders as it changes.
Field arrays
useFieldArray manages a dynamic list of repeating field groups — guests on an invite, line
items on an order. The handle’s fields re-renders when rows are added, removed, or
reordered; each row carries a stable id you use both as the reconciler key and inside
the registered field paths:
val fa = useFieldArray(f.control, "guests", initial = Seq(Map("name" -> "")))
div(
fa.fields.map { row =>
div(key := row.id)(
input(f.register(s"guests.${row.id}.name")*),
button(typ := "button", onClick := (_ => fa.remove(row.index)), "Remove"),
)
},
button(typ := "button", onClick := (_ => fa.append(Map("name" -> ""))), "Add guest"),
)
Addressing a row by its id rather than its index is what keeps remove/move/insert robust
over the flat string-path store: reordering never reshuffles stored values or invalidates a
registered field. The handle exposes append/prepend/insert/remove/move/swap/
replace, and values — the rows as ordered maps of sub-field → value, the view to read at
submit time.