Cookbook
Forms and validation
Validation rules written three times drift. Written once in the core, a form is a few signals and one command, and every platform draws the same errors.
The recipe#
- A signal per field. Each text field binds to a setter (
set_email); the core holds the value, so a rotation or a restore loses nothing. - Errors are derived.
errorsis aComputedof the fields: recomputed when one changes, never by the UI, and sent to the platforms only when it changed. It shows the problems of the fields the user has left, so an empty form is not an angry form. - The rules are plain functions.
problemstakes values and returns problems: testable without a runtime, and the one place a rule is written. - The command refuses typed.
submiton an invalid form returnsInvalidbefore any request, shows every problem from then on, and the server's "taken" is a case (EmailTaken), not a message to parse.
Show the Rust
/// Every problem of the form as it stands, in field order. A pure function: the one place the
/// rules are written, easy to test without a runtime.
fn problems(email: &str, password: &str, terms: bool) -> Vec<FieldError> {
let mut out = Vec::new();
let mut add = |field, message: &str| {
out.push(FieldError {
field,
message: message.to_owned(),
})
};
let email = email.trim();
match email.split_once('@') {
Some((name, host)) if !name.is_empty() && host.contains('.') && !host.ends_with('.') => {}
_ => add(Field::Email, "Enter an email address like name@example.com"),
}
if password.chars().count() < 8 || !password.chars().any(|c| c.is_ascii_digit()) {
add(Field::Password, "Use at least 8 characters, with a number");
}
if !terms {
add(Field::Terms, "Accept the terms to continue");
}
out
}/// The sign-up form's store.
#[undra::store(restore = "Self::assemble")]
pub struct SignUp {
ctx: WeakCtx,
email: Signal<String>,
password: Signal<String>,
terms: Signal<bool>,
/// The fields the user has left, whose problems are shown.
touched: Signal<Vec<Field>>,
submitting: Signal<bool>,
/// The problems of the touched fields.
errors: Computed<Vec<FieldError>>,
/// Whether the form can be submitted.
valid: Computed<bool>,
}/// Creates the account and returns its id. An invalid form is refused with
/// `Invalid` (and every problem is shown from then on); nothing is sent.
pub async fn submit(&self) -> Result<u32, SubmitError> {
if self.submitting.get() {
return Err(SubmitError::Busy);
}
let found = problems(&self.email.get(), &self.password.get(), self.terms.get());
if !found.is_empty() {
self.touched
.set(vec![Field::Email, Field::Password, Field::Terms]);
return Err(SubmitError::Invalid {
count: u32::try_from(found.len()).unwrap_or(u32::MAX),
});
}
let ctx = self.ctx.upgrade().map_err(|_| NetError::Closed)?;
self.submitting.set(true);
let result = self.send(&ctx).await;
self.submitting.set(false);
result
}On the platform#
A field is a binding to a setter; the errors and the button state are signals.
Show the Swift, Kotlin and TypeScript
struct SignUpView: View {
let form: SignUp
var body: some View {
Form {
TextField("Email", text: Binding(get: { form.email }, set: { form.setEmail(email: $0) }))
.onSubmit { form.blur(.email) }
ForEach(form.errors, id: \.message) { error in
Text(error.message).foregroundStyle(.red)
}
Button("Create account") {
Task {
do { _ = try await form.submit() }
catch SubmitError.emailTaken { /* point at the email field */ }
catch { /* Invalid: the errors above already say why */ }
}
}
.disabled(!form.valid)
}
}
}class SignUpScreen(private val form: SignUp) {
val errors: StateFlow<List<FieldError>> = form.errors
val canSubmit: StateFlow<Boolean> = form.valid
fun onEmail(text: String) = form.setEmail(text)
fun onEmailLeft() = form.blur(Field.EMAIL)
suspend fun create(): String? =
try {
form.submit()
null
} catch (e: SubmitError.EmailTaken) {
"That email is already registered"
} catch (e: SubmitError.Invalid) {
null // `errors` already says why
}
}export function SignUpView({ form }: { form: SignUp }) {
const errors = useSignal(form.errors);
const valid = useSignal(form.valid);
return (
<form
onSubmit={async (e) => {
e.preventDefault();
try {
await form.submit();
} catch (error) {
if (error instanceof SubmitError.EmailTaken) alert("That email is already registered");
}
}}
>
<input type="email" onChange={(e) => void form.setEmail(e.target.value)} onBlur={() => void form.blur("email")} />
{errors.map((error) => <p key={error.message}>{error.message}</p>)}
<button disabled={!valid}>Create account</button>
</form>
);
}What the tests prove#
- Nothing is shown until a field is left; the error goes as soon as the field is right.
- An invalid submit makes no request and shows all three problems; a second submit while one runs is refused.
- A valid submit sends the trimmed email; a
409isEmailTaken, a500is a network case, and the form can be submitted again.
Limits#
- Messages are plain English strings from the core. For several languages return a code (an enum) and let each platform localise it, or ask a synchronous port the way the playground's
Localedoes. - The server must still validate: this is the client's half, written once instead of three times.
Source: examples/cookbook/core/src/forms.rs.