Form Validation¶
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.column({}, [
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:
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
- forward navigation waits for
validateAsyncwhen configured and only advances after the step validates cleanly - 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("");