Validation
Learn about Zod integration, async validation, and error handling patterns.
Zod Integration
Basic Schema Validation (onDynamic)
The simplest way to add validation is to define a Zod schema and pass it to validators.onDynamic with revalidateLogic to control timing:
import { hasErrors } from "@tilt-legal/cubitt-components/form";
import {
z } from "zod";
const schema = z.object({
email: z.string().email("Please enter a valid email"),
age: z.number().min(18,
"You must be at least 18 years old"),
password: z.string().min(8,
"Password must be at least 8 characters"),
});
const form = useForm({
validationLogic: revalidateLogic({
mode: "submit",
modeAfterSubmission: "change",
}),
validators: {
onDynamic: schema,
},
// ...
});Cross-Field Validation
Use Zod's refine method for validation that depends on multiple fields:
const schema = z
.object({
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword,
{
message: "Passwords don't match",
path: ["confirmPassword"],
// Error appears on confirmPassword field
});Complex Validation Rules
Zod supports sophisticated validation patterns:
const userSchema = z.object({
username: z
.string()
.min(3,
"Username must be at least 3 characters")
.max(20,
"Username must be less than 20 characters")
.regex(/^[a-zA-Z0-9_]+$/,
"Only letters,
numbers,
and underscores allowed"),
email: z
.string()
.email("Invalid email address")
.refine((email) => !email.endsWith("@temp.com"),
{
message: "Temporary email addresses are not allowed",
}),
age: z.coerce
.number()
.int("Age must be a whole number")
.min(13,
"You must be at least 13 years old")
.max(120,
"Please enter a valid age"),
website: z
.string()
.url("Please enter a valid URL")
.optional()
.or(z.literal("")),
// Allow empty string
});Custom Validation
Client-Side Custom Validation
For validation logic that can't be expressed with Zod, use custom validators:
const form = useForm({
validators: {
onChange: ({ value }) => {
const errors: Record<string,
string> = {};
// Custom validation logic
if (value.username && value.username.includes("admin")) {
errors.username = "Username cannot contain 'admin'";
}
if (value.password !== value.confirmPassword) {
errors.confirmPassword = "Passwords must match";
}
// Return errors object or undefined if no errors
return Object.keys(errors).length > 0 ? { fields: errors } : undefined;
},
},
});Combining Zod and Custom Validation
You can use both Zod and custom validation together with onDynamic channels:
const form = useForm({
validationLogic: revalidateLogic({
mode: "submit",
modeAfterSubmission: "change",
}),
validators: {
onDynamic: zodSchema,
onDynamicAsyncDebounceMs: 500,
onDynamicAsync: async ({ value }) => customAsyncValidation(value),
},
});Async Validation
Form with async validation for checking username availability or other server-side checks.
const checkUsernameAvailability = async (username: string) => {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve,
500));
const taken = ["admin",
"user",
"test"].includes(username.toLowerCase());
return !taken;
};
const asyncSchema = z.object({
username: z.string().min(3,
"Username must be at least 3 characters"),
email: z.string().email("Invalid email address"),
});
export function AsyncValidationExample() {
const form = useForm({
defaultValues: {
username: "",
email: "",
},
validationLogic: revalidateLogic({
mode: "submit",
modeAfterSubmission: "change",
}),
validators: {
onDynamic: asyncSchema,
onDynamicAsyncDebounceMs: 500,
onDynamicAsync: async ({ value }) => {
if (value.username && value.username.length >= 3) {
const available = await checkUsernameAvailability(value.username);
if (!available) {
return { fields: { username: "Username is already taken" } };
}
}
return undefined;
},
},
onSubmit: async ({ value }) => {
console.log("Submitted:",
value);
alert("Username is available!");
},
});
const isValidating = useStore(
form.store,
(s) => s.fieldMeta.username?.isValidating ?? false
);
return (
<Form form={form} className="space-y-4 max-w-md">
<InputField
name="username"
label="Username"
placeholder="Choose a unique username"
disabled={isValidating}
/>
<InputField
name="email"
label="Email"
type="email"
placeholder="your@email.com"
/>
<Button type="submit">Check Availability</Button>
</Form>
);
}Async Validation Patterns
Debounced Validation
Prevent excessive API calls by debouncing async validation:
const form = useForm({
validators: {
onDynamicAsyncDebounceMs: 500,
onDynamicAsync: async ({ value }) => {
if (value.username) {
const isAvailable = await checkUsername(value.username);
if (!isAvailable) return { fields: { username: "Username taken" } };
}
},
},
});Conditional Async Validation
Only run async validation when certain conditions are met:
validators: {
onDynamicAsync: async ({ value }) => {
// Only check username if it meets basic requirements
if (value.username && value.username.length >= 3) {
const result = await validateUsername(value.username);
if (!result.isValid) {
return { fields: { username: result.message } };
}
}
},
}Multiple Async Checks
Validate multiple fields asynchronously:
validators: {
onDynamicAsync: async ({ value }) => {
const errors: Record<string,
string> = {};
// Check username availability
if (value.username && value.username.length >= 3) {
const usernameAvailable = await checkUsername(value.username);
if (!usernameAvailable) {
errors.username = "Username is taken";
}
}
// Check email availability
if (value.email && value.email.includes('@')) {
const emailAvailable = await checkEmail(value.email);
if (!emailAvailable) {
errors.email = "Email is already registered";
}
}
return Object.keys(errors).length > 0 ? { fields: errors } : undefined;
},
}Error Handling
Form Submission Errors
Handle errors that occur during form submission:
const form = useForm({
onSubmit: async ({ value,
formApi }) => {
try {
await submitUserData(value);
alert("Success! Account created.");
} catch (error) {
if (error instanceof ValidationError) {
// Server returned validation errors - use updater function
formApi.setFieldMeta("username",
(prev) => ({
...prev,
errorMap: { onSubmit: error.message },
}));
} else {
// Generic error handling
console.error("Submission failed:",
error);
alert("Something went wrong. Please try again.");
}
}
},
});Field-Level Error Display
Access and display field-specific errors:
// Using the clean API - errors are handled automatically
<InputField name="username" label="Username" />
// Or access errors manually in advanced scenarios
<form.Field name="username">
{(field) => {
const hasError = field.state.meta.errors.length > 0;
return (
<div>
<InputField field={field} label="Username" />
{hasError && (
<p className="text-red-500 text-sm mt-1">
{field.state.meta.errors[0]}
</p>
)}
</div>
);
}}
</form.Field>Server-Side Validation
Handle validation errors returned from the server:
const form = useForm({
onSubmit: async ({ value,
formApi }) => {
try {
await createAccount(value);
} catch (error) {
if (error.status === 422) {
// Server returned field-specific validation errors
const serverErrors = error.response.data.errors;
// Map server errors to form fields using the updater pattern
for (const [fieldName,
message] of Object.entries(serverErrors)) {
formApi.setFieldMeta(fieldName,
(prev) => ({
...prev,
errorMap: { onSubmit: message },
}));
}
}
}
},
});Validation Timing
onChange vs onSubmit
Choose when validation runs based on your UX requirements:
const form = useForm({
validators: {
// Real-time validation as user types
onChange: schema,
// Only validate when user submits
onSubmit: schema,
// Async validation (always debounced)
onChangeAsync: asyncValidator,
},
});Field-Level Validation Timing
Control validation timing for individual fields:
<form.Field
name="password"
validators={{
onChange: ({ value }) =>
value.length < 8 ? "Password too short" : undefined,
onBlur: ({ value }) => validatePasswordStrength(value),
}}
>
{(field) => <InputField field={field} />}
</form.Field>Helper Utilities
Cubitt provides type-safe helper functions for common form operations:
Error Extraction
import { extractErrorMessages } from "@tilt-legal/cubitt-components/form";
// Extract human-readable error messages from field meta
const errors = extractErrorMessages(field.state.meta.errors);
// ["Name is required", "Must be at least 3 characters"]
// Check if field has any validation errors
if (hasErrors(field.state.meta.errors)) {
// Show error styling
}Form Helpers
For advanced scenarios where you need to programmatically manipulate form state:
import {
getFieldValue,
setFieldErrors,
setFieldMeta,
setFieldValue,
validateField,
validateForm,
} from "@tilt-legal/cubitt-components/form";
// Set custom errors on a field
setFieldErrors(form, "email", ["Email is already registered"]);
// Programmatically set a field value
setFieldValue(form, "status", "approved");
// Trigger form-wide validation
validateForm(form);
// Validate a specific field
validateField(form, "email", "blur");These utilities are primarily used internally by the FormBuilder components. For most use cases, the standard TanStack Form API is sufficient.