Recipes
Form Validation
Implementing input validation with error display in Rezi forms.
Implementing input validation with error display in Rezi forms.
Problem
You need to validate user input and display appropriate error messages before submitting a form.
Solution
Use controlled inputs with validation functions that update error state.
Complete Example
This is a complete, runnable example (save as form.ts and run with npx tsx form.ts):
import { ui, rgb } from "@rezi-ui/core";
import { createNodeApp } from "@rezi-ui/node";
type FormState = {
email: string;
password: string;
errors: { email?: string; password?: string };
touched: { email: boolean; password: boolean };
};
function validateEmail(email: string): string | undefined {
if (!email) return "Email is required";
if (!email.includes("@")) return "Invalid email format";
return undefined;
}
function validatePassword(password: string): string | undefined {
if (!password) return "Password is required";
if (password.length < 8) return "Password must be at least 8 characters";
return undefined;
}
const app = createNodeApp<FormState>({
initialState: {
email: "",
password: "",
errors: {},
touched: { email: false, password: false },
},
});
function validateAll(s: FormState): FormState["errors"] {
return {
email: validateEmail(s.email),
password: validatePassword(s.password),
};
}
app.view((state) => {
const errors = state.errors;
const touched = state.touched;
const canSubmit = !errors.email && !errors.password && state.email.length > 0 && state.password.length > 0;
return ui.column({ gap: 1, p: 1 }, [
ui.text("Sign Up", { style: { bold: true } }),
ui.field({
label: "Email",
required: true,
error: touched.email ? errors.email : undefined,
children: ui.input({
id: "email",
value: state.email,
onInput: (value) =>
app.update((s) => {
const next = { ...s, email: value };
return { ...next, errors: validateAll(next) };
}),
onBlur: () => app.update((s) => ({ ...s, touched: { ...s.touched, email: true } })),
}),
}),
ui.field({
label: "Password",
required: true,
hint: "At least 8 characters",
error: touched.password ? errors.password : undefined,
children: ui.input({
id: "password",
value: state.password,
onInput: (value) =>
app.update((s) => {
const next = { ...s, password: value };
return { ...next, errors: validateAll(next) };
}),
onBlur: () =>
app.update((s) => ({ ...s, touched: { ...s.touched, password: true } })),
}),
}),
ui.row({ gap: 1, justify: "end" }, [
ui.button({ id: "submit", label: "Create account", disabled: !canSubmit }),
]),
!canSubmit &&
ui.text("Fix validation errors to enable submission.", { style: { fg: rgb(255, 110, 110) } }),
]);
});
app.keys({
"ctrl+c": () => app.stop(),
q: () => app.stop(),
});
await app.start();Explanation
- Inputs are controlled:
valuecomes from state andonInputupdates state. - Validation runs inside the update function so it stays deterministic and doesn’t run during render.
touchedis set ononBlurso errors only display after the user leaves a field.
Boilerplate-Free useForm Wiring
For larger forms, prefer useForm helpers that auto-wire id, value, onInput, onBlur, and touched error display:
import { ui, useForm } from "@rezi-ui/core";
type LoginValues = {
email: string;
password: string;
};
const form = useForm(ctx, {
initialValues: { email: "", password: "" },
validate: (values) => ({
email: values.email.includes("@") ? undefined : "Enter a valid email",
password: values.password.length >= 8 ? undefined : "Minimum 8 characters",
}),
onSubmit: (values) => {
// submit values
},
});
return ui.vstack([
form.field("email", { label: "Email", required: true }),
form.field("password", { label: "Password", required: true, hint: "Minimum 8 characters" }),
ui.button({
id: "submit",
label: "Sign in",
onPress: form.handleSubmit,
}),
]);Use form.bind("fieldName") when you only need the input itself:
ui.input(form.bind("email"));useForm Advanced Features
@rezi-ui/core also provides a richer useForm API for more complex flows:
- Ergonomic input wiring:
ui.input(form.bind("email"))form.field("email", { label: "Email", required: true })
- Field arrays with deterministic keys and state-preserving mutations:
const fields = form.useFieldArray("items")fields.append(item),fields.remove(index),fields.move(from, to)
- Wizard flow with step gates:
- configure
wizard.stepsinuseFormoptions - navigate with
form.nextStep(),form.previousStep(),form.goToStep(index) - backward navigation does not re-run validation
- configure
- Form-level disabled/readOnly with per-field overrides:
form.setDisabled(true)/form.setReadOnly(true)form.setFieldDisabled("name", false)andform.setFieldReadOnly("name", false)override form-level flags
Example: Wizard + Field Array
import { useForm } from "@rezi-ui/core/forms";
type Values = {
name: string;
emails: string[];
};
const form = useForm(ctx, {
initialValues: { name: "", emails: [""] },
validate: (v) => ({
name: v.name ? undefined : "Required",
emails: v.emails.map((email) => (email.includes("@") ? undefined : "Invalid email")),
}),
wizard: {
steps: [
{ id: "profile", fields: ["name"] },
{ id: "emails", fields: ["emails"] },
],
},
onSubmit: (values) => {
// handle values
},
});
const emails = form.useFieldArray("emails");
emails.append("");