Handle Forms
Goal: a controlled form whose inputs drive SubscriptionRef state, whose errors update reactively as the user types, and whose submit runs an Effect.
Each field is a SubscriptionRef. Bind it with oninput, derive validation from its .changes stream, and return an Effect from onsubmit (after preventDefault).
import { h } from "@weftui/core";
import { Effect, Either, Schema, Stream, SubscriptionRef } from "effect";
const Email = Schema.String.pipe(
Schema.filter((s) => s.includes("@"), { message: () => "Must contain @" }),
Schema.filter((s) => s.includes("."), { message: () => "Must contain a domain" }),
);
const LoginForm = () =>
Effect.gen(function* () {
const email = yield* SubscriptionRef.make("");
const status = yield* SubscriptionRef.make<string | null>(null);
// Validation is a stream derived from the field — it re-runs as the user types.
const error = Stream.map(email.changes, (value) => {
if (value.length === 0) return null; // don't nag an empty field
return Either.match(Schema.decodeUnknownEither(Email)(value), {
onLeft: (e) => e.message.split(":").pop()?.trim() ?? "Invalid",
onRight: () => null,
});
});
return yield* h.form(
{
onsubmit: (e) => {
e.preventDefault();
return Effect.gen(function* () {
yield* SubscriptionRef.set(status, "Submitting…");
yield* Effect.sleep("1500 millis");
yield* SubscriptionRef.set(status, "Login successful!");
});
},
},
[
h.input({
type: "email",
oninput: (e) => SubscriptionRef.set(email, (e.target as HTMLInputElement).value),
}),
Stream.map(error, (err) => (err ? h.span({ class: "error-text" }, err) : null)),
h.button({ type: "submit" }, "Login"),
h.div([Stream.map(status.changes, (s) => (s ? h.span(s) : null))]),
],
);
});How it works
- Field state is a
SubscriptionRef.make("");oninputwrites the current value withSubscriptionRef.set. Because the input is driven by the ref, it is a controlled input. - Validation is reactive, not on-blur or on-submit only:
Stream.map(email.changes, …)produces an error string (ornull) on every keystroke. UseSchemato decode —Schema.decodeUnknownEither(schema)(value)returns anEither, andEither.matchturns it into UI. A node ornullin a child slot renders the error or nothing. - Submit returns an Effect.
onsubmitcallse.preventDefault()and then returns anEffect(it is notyield*-ed inline) — the renderer runs it in a detached fiber, so it canSubscriptionRef.set,Effect.sleep, read fields withSubscriptionRef.get, or call a service.
Variations
- Cross-field rules (e.g. "passwords match") combine two fields with
Stream.zipLatestWithbefore mapping to an error. - Read a field imperatively inside the submit handler with
yield* SubscriptionRef.get(field)rather than threading it through.
See also
- Reactive Primitives —
SubscriptionRef.changesand stream-shaped children - Author Components — Effect-returning and service-aware handlers
- examples/form-handling — a runnable multi-field form with Schema validation and an async submit