Form Validation with Zod
This guide shows how to build type-safe forms using Cubit and Zod v4. You'll learn patterns for handling validation, touched states, async validation, and creating reusable form abstractions.
Why Cubit for Forms?
Forms are a natural fit for Cubit because:
- Direct state updates - Form fields change frequently; Cubit's
patch()andupdate()methods make this simple - Derived state - Computed getters like
isValidorisDirtyare automatically tracked - No event overhead - Unlike Vertex, you don't need to define events for every field change
- Isolated instances - Each form component gets its own Cubit instance by default
Step 1: Define the Schema
Start by defining your validation schema with Zod. This gives you both runtime validation and TypeScript types from a single source of truth:
import { z } from 'zod';
// Define the schema with validation rules and error messages
const LoginSchema = z.object({
email: z.string().email({ error: 'Please enter a valid email' }),
password: z.string().min(8, { error: 'Password must be at least 8 characters' }),
});
// Infer the TypeScript type from the schema
// This ensures your form values always match your validation rules
type LoginForm = z.infer<typeof LoginSchema>;
// => { email: string; password: string }Step 2: Define Form State
Form state typically includes more than just values. Track errors and submission status:
interface FormState<T> {
values: T; // Current form values
errors: Partial<Record<keyof T, string>>; // Validation errors per field
touched: Partial<Record<keyof T, boolean>>; // Which fields have been interacted with
isSubmitting: boolean; // Prevents double-submission
}Why track touched? Users don't want to see errors before they've had a chance to fill in a field. By tracking which fields have been touched, you can show errors only after the user has interacted with that field.
Step 3: Build the Form Cubit
import { Cubit } from '@blac/core';
class LoginFormCubit extends Cubit<FormState<LoginForm>> {
constructor() {
super({
values: { email: '', password: '' },
errors: {},
touched: {},
isSubmitting: false,
});
}
// Update a field value and mark it as touched
setField = <K extends keyof LoginForm>(field: K, value: LoginForm[K]) => {
this.update((state) => ({
...state,
values: { ...state.values, [field]: value },
touched: { ...state.touched, [field]: true },
}));
// Validate immediately for real-time feedback
this.validateField(field);
};
// Validate a single field using the schema
private validateField = (field: keyof LoginForm) => {
// Extract the specific field's schema for targeted validation
const fieldSchema = LoginSchema.shape[field];
const result = fieldSchema.safeParse(this.state.values[field]);
this.update((state) => ({
...state,
errors: {
...state.errors,
[field]: result.success ? undefined : result.error.issues[0]?.message,
},
}));
};
// Validate all fields before submission
validateAll = (): boolean => {
const result = LoginSchema.safeParse(this.state.values);
if (!result.success) {
const errors: Partial<Record<keyof LoginForm, string>> = {};
// Map Zod issues to field errors (take first error per field)
for (const issue of result.error.issues) {
const field = issue.path[0] as keyof LoginForm;
if (!errors[field]) {
errors[field] = issue.message;
}
}
// Mark all fields as touched so errors display
const touched = Object.keys(this.state.values).reduce(
(acc, key) => ({ ...acc, [key]: true }),
{} as Record<keyof LoginForm, boolean>
);
this.patch({ errors, touched });
return false;
}
this.patch({ errors: {} });
return true;
};
submit = async (onSuccess: (values: LoginForm) => Promise<void>) => {
if (!this.validateAll()) return;
this.patch({ isSubmitting: true });
try {
await onSuccess(this.state.values);
} catch (error) {
// Handle API errors (e.g., "email already registered")
if (error instanceof Error) {
this.patch({ errors: { email: error.message } });
}
} finally {
this.patch({ isSubmitting: false });
}
};
// Computed: check if form has any validation errors
get isValid() {
return Object.values(this.state.errors).every((error) => error === undefined);
}
// Computed: check if user has interacted with the form
get isDirty() {
return Object.values(this.state.touched).some(Boolean);
}
// Helper: get error only if field has been touched
getFieldError = (field: keyof LoginForm): string | undefined => {
return this.state.touched[field] ? this.state.errors[field] : undefined;
};
}Step 4: React Integration
import { useBloc } from '@blac/react';
function LoginForm() {
const form = useBloc(LoginFormCubit);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await form.submit(async (values) => {
await api.login(values);
navigate('/dashboard');
});
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={form.state.values.email}
onChange={(e) => form.setField('email', e.target.value)}
aria-invalid={!!form.getFieldError('email')}
aria-describedby={form.getFieldError('email') ? 'email-error' : undefined}
/>
{form.getFieldError('email') && (
<span id="email-error" className="error" role="alert">
{form.getFieldError('email')}
</span>
)}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={form.state.values.password}
onChange={(e) => form.setField('password', e.target.value)}
aria-invalid={!!form.getFieldError('password')}
/>
{form.getFieldError('password') && (
<span className="error" role="alert">
{form.getFieldError('password')}
</span>
)}
</div>
<button type="submit" disabled={form.state.isSubmitting}>
{form.state.isSubmitting ? 'Signing in...' : 'Sign In'}
</button>
</form>
);
}Async Validation with Debouncing
Some validations require API calls, like checking if a username is available. Always debounce these to avoid excessive requests:
import { Cubit } from '@blac/core';
import { z } from 'zod';
const SignupSchema = z.object({
username: z
.string()
.min(3, { error: 'Username must be at least 3 characters' })
.max(20, { error: 'Username must be at most 20 characters' })
.regex(/^[a-z0-9_]+$/, { error: 'Only lowercase letters, numbers, and underscores' }),
email: z.string().email({ error: 'Invalid email' }),
});
type SignupForm = z.infer<typeof SignupSchema>;
interface SignupState {
values: SignupForm;
errors: Partial<Record<keyof SignupForm, string>>;
touched: Partial<Record<keyof SignupForm, boolean>>;
validating: Partial<Record<keyof SignupForm, boolean>>; // Track async validation
isSubmitting: boolean;
}
class SignupFormCubit extends Cubit<SignupState> {
private usernameCheckTimeout: ReturnType<typeof setTimeout> | null = null;
private lastCheckedUsername = '';
constructor() {
super({
values: { username: '', email: '' },
errors: {},
touched: {},
validating: {},
isSubmitting: false,
});
}
setUsername = (username: string) => {
// Clear any pending async validation
if (this.usernameCheckTimeout) {
clearTimeout(this.usernameCheckTimeout);
}
this.update((state) => ({
...state,
values: { ...state.values, username },
touched: { ...state.touched, username: true },
}));
// Step 1: Run synchronous validation first
const syncResult = SignupSchema.shape.username.safeParse(username);
if (!syncResult.success) {
this.update((state) => ({
...state,
errors: { ...state.errors, username: syncResult.error.issues[0]?.message },
validating: { ...state.validating, username: false },
}));
return;
}
// Step 2: Debounce async validation (300ms wait)
this.update((state) => ({
...state,
errors: { ...state.errors, username: undefined },
validating: { ...state.validating, username: true },
}));
this.usernameCheckTimeout = setTimeout(async () => {
// Skip if username changed while waiting
if (username !== this.state.values.username) return;
// Skip if we already checked this username
if (username === this.lastCheckedUsername) return;
try {
const available = await api.checkUsernameAvailable(username);
this.lastCheckedUsername = username;
// Only update if username hasn't changed during the API call
if (username === this.state.values.username) {
this.update((state) => ({
...state,
errors: {
...state.errors,
username: available ? undefined : 'Username is already taken',
},
validating: { ...state.validating, username: false },
}));
}
} catch {
this.update((state) => ({
...state,
validating: { ...state.validating, username: false },
}));
}
}, 300);
};
// Show loading indicator during async validation
get isValidating() {
return Object.values(this.state.validating).some(Boolean);
}
// Cleanup timer when Cubit is disposed
protected override onDispose = () => {
if (this.usernameCheckTimeout) {
clearTimeout(this.usernameCheckTimeout);
}
};
}Key points:
- Debounce API calls to reduce server load
- Track
validatingstate to show loading indicators - Check if the value changed during async operation before updating state
- Clean up timers in
onDispose
Complex Nested Schemas
For forms with nested objects like addresses, use dot-notation paths for error tracking:
const AddressSchema = z.object({
street: z.string().min(1, { error: 'Street is required' }),
city: z.string().min(1, { error: 'City is required' }),
zip: z.string().regex(/^\d{5}(-\d{4})?$/, { error: 'Invalid ZIP code' }),
country: z.enum(['US', 'CA', 'UK'], { error: 'Select a country' }),
});
const CheckoutSchema = z
.object({
items: z
.array(
z.object({
productId: z.string(),
quantity: z.number().min(1).max(99),
})
)
.min(1, { error: 'Add at least one item' }),
shippingAddress: AddressSchema,
billingAddress: AddressSchema.optional(),
sameAsShipping: z.boolean(),
})
.refine(
// Cross-field validation: billing required unless same as shipping
(data) => data.sameAsShipping || data.billingAddress !== undefined,
{
error: 'Billing address is required when different from shipping',
path: ['billingAddress'], // Attach error to specific field
}
);
type CheckoutForm = z.infer<typeof CheckoutSchema>;
class CheckoutFormCubit extends Cubit<{
values: CheckoutForm;
errors: Record<string, string>; // Use string keys for nested paths
isSubmitting: boolean;
}> {
// Validate and convert Zod issues to dot-notation paths
validate = (): boolean => {
const result = CheckoutSchema.safeParse(this.state.values);
if (!result.success) {
const errors: Record<string, string> = {};
for (const issue of result.error.issues) {
// Convert path array to dot notation: ['shippingAddress', 'city'] => 'shippingAddress.city'
const path = issue.path.join('.');
if (!errors[path]) {
errors[path] = issue.message;
}
}
this.patch({ errors });
return false;
}
this.patch({ errors: {} });
return true;
};
// Helper to get error for any path
getError = (path: string): string | undefined => {
return this.state.errors[path];
};
// Update nested value using path
setNestedField = (path: string, value: unknown) => {
this.update((state) => {
const newValues = { ...state.values };
const parts = path.split('.');
let current: Record<string, unknown> = newValues;
// Navigate to parent and set value
for (let i = 0; i < parts.length - 1; i++) {
current[parts[i]] = { ...(current[parts[i]] as Record<string, unknown>) };
current = current[parts[i]] as Record<string, unknown>;
}
current[parts[parts.length - 1]] = value;
return { ...state, values: newValues as CheckoutForm };
});
};
}Usage in React:
function AddressFields({ prefix }: { prefix: 'shippingAddress' | 'billingAddress' }) {
const form = useBloc(CheckoutFormCubit);
return (
<fieldset>
<input
value={form.state.values[prefix]?.street ?? ''}
onChange={(e) => form.setNestedField(`${prefix}.street`, e.target.value)}
placeholder="Street"
/>
{form.getError(`${prefix}.street`) && (
<span className="error">{form.getError(`${prefix}.street`)}</span>
)}
{/* ... more fields */}
</fieldset>
);
}Reusable Form Base Class
For applications with many forms, create a reusable base class:
import { Cubit } from '@blac/core';
import { z } from 'zod';
interface FormState<T> {
values: T;
errors: Partial<Record<keyof T, string>>;
touched: Partial<Record<keyof T, boolean>>;
isSubmitting: boolean;
}
abstract class FormCubit<T extends Record<string, unknown>> extends Cubit<FormState<T>> {
protected abstract schema: z.ZodType<T>;
constructor(initialValues: T) {
super({
values: initialValues,
errors: {},
touched: {},
isSubmitting: false,
});
}
setField = <K extends keyof T>(field: K, value: T[K]) => {
this.update((state) => ({
...state,
values: { ...state.values, [field]: value },
touched: { ...state.touched, [field]: true },
}));
this.validateField(field);
};
private validateField = (field: keyof T) => {
// For object schemas, validate individual field
if (this.schema instanceof z.ZodObject) {
const fieldSchema = this.schema.shape[field as string];
if (fieldSchema) {
const result = fieldSchema.safeParse(this.state.values[field]);
this.update((state) => ({
...state,
errors: {
...state.errors,
[field]: result.success ? undefined : result.error.issues[0]?.message,
},
}));
}
}
};
validate = (): boolean => {
const result = this.schema.safeParse(this.state.values);
if (!result.success) {
const errors: Partial<Record<keyof T, string>> = {};
const touched: Partial<Record<keyof T, boolean>> = {};
for (const issue of result.error.issues) {
const field = issue.path[0] as keyof T;
if (!errors[field]) {
errors[field] = issue.message;
}
touched[field] = true;
}
this.patch({ errors, touched });
return false;
}
this.patch({ errors: {} });
return true;
};
reset = (values?: Partial<T>) => {
this.emit({
values: values ? { ...this.state.values, ...values } : this.state.values,
errors: {},
touched: {},
isSubmitting: false,
});
};
get isValid() {
return Object.values(this.state.errors).every((e) => e === undefined);
}
get isDirty() {
return Object.values(this.state.touched).some(Boolean);
}
getFieldError = (field: keyof T): string | undefined => {
return this.state.touched[field] ? this.state.errors[field] : undefined;
};
}Now creating a new form is simple:
const ContactSchema = z.object({
name: z.string().min(1, { error: 'Name is required' }),
email: z.string().email({ error: 'Invalid email address' }),
subject: z.string().min(1, { error: 'Subject is required' }),
message: z.string().min(10, { error: 'Message must be at least 10 characters' }),
});
class ContactFormCubit extends FormCubit<z.infer<typeof ContactSchema>> {
protected schema = ContactSchema;
constructor() {
super({ name: '', email: '', subject: '', message: '' });
}
submit = async () => {
if (!this.validate()) return;
this.patch({ isSubmitting: true });
try {
await api.sendContactForm(this.state.values);
this.reset({ name: '', email: '', subject: '', message: '' });
} finally {
this.patch({ isSubmitting: false });
}
};
}Common Pitfalls
1. Showing errors before user interaction
// ❌ Bad: Shows error immediately
{form.state.errors.email && <span>{form.state.errors.email}</span>}
// ✅ Good: Only show after user has touched the field
{form.getFieldError('email') && <span>{form.getFieldError('email')}</span>}2. Not handling API errors
// ❌ Bad: Ignores server-side validation errors
submit = async () => {
await api.register(this.state.values);
};
// ✅ Good: Maps API errors to form fields
submit = async () => {
try {
await api.register(this.state.values);
} catch (error) {
if (error.code === 'EMAIL_EXISTS') {
this.patch({ errors: { email: 'This email is already registered' } });
}
}
};3. Missing validation on blur
For better UX, validate when a field loses focus:
<input
onBlur={() => form.validateField('email')}
onChange={(e) => form.setField('email', e.target.value)}
/>See Also
- Cubit - Cubit fundamentals
- useBloc - React hook usage
- Zod Documentation - Full Zod API reference