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#

Show the Rust
core/src/forms.rs
/// 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
}
core/src/forms.rs
/// 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>,
}
core/src/forms.rs
/// 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
swift
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)
        }
    }
}
kotlin
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
        }
}
tsx
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#

Limits#

Source: examples/cookbook/core/src/forms.rs.

Edit this page on GitHub