Skip to main content

Validation

El Form's schema-agnostic validation is its core feature. Unlike other form libraries that tie you to specific validation libraries, El Form works with any validation approach.

The Schema-Agnostic Approach

Most form libraries require you to use specific "resolvers" or validation adapters. El Form takes a different approach: it accepts any function that can validate data and return errors.

// This is all El Form needs from a validator
type ValidatorFunction = (values: any) => {
errors?: Record<string, string>;
isValid: boolean;
};

This simple interface means El Form can work with any validation library or custom logic.

Standard Schema support

El Form automatically detects any validator that implements the Standard Schema spec — i.e. it exposes a ~standard property — and validates through that protocol. So Valibot, ArkType, and any other Standard Schema-compliant library work out of the box, with no resolver or adapter to install. (Zod and Yup are detected via their own shapes as well.)

Validation Libraries

Try it live

Edit this example in the interactive Playground.

Zod provides excellent TypeScript integration and runtime safety:

import { z } from "zod";
import { useForm } from "el-form-react-hooks";

const schema = z.object({
email: z.string().email("Please enter a valid email"),
password: z.string().min(8, "Password must be at least 8 characters"),
age: z.number().min(18, "Must be 18 or older"),
});

const form = useForm({
validators: { onChange: schema },
defaultValues: { email: "", password: "", age: 18 },
});

Yup

Yup is a popular choice with a different API style:

import * as yup from "yup";

const schema = yup.object({
email: yup.string().email("Invalid email").required("Email required"),
password: yup.string().min(8, "Too short").required("Password required"),
confirmPassword: yup
.string()
.oneOf([yup.ref("password")], "Passwords must match")
.required("Confirm password required"),
});

const form = useForm({
validators: { onChange: schema },
});

Valibot

Valibot offers a modular, functional approach:

import * as v from "valibot";

const schema = v.object({
email: v.pipe(v.string(), v.email("Please enter a valid email")),
username: v.pipe(
v.string(),
v.minLength(3, "Too short"),
v.maxLength(20, "Too long")
),
});

const form = useForm({
validators: { onChange: schema },
});

Custom Validation Functions

Sometimes you need custom logic that no schema library provides:

const customValidator = (values: any) => {
const errors: Record<string, string> = {};

// Custom email validation
if (!values.email?.includes("@")) {
errors.email = "Email must contain @ symbol";
}

// Business logic validation
if (values.startDate && values.endDate) {
if (new Date(values.startDate) >= new Date(values.endDate)) {
errors.endDate = "End date must be after start date";
}
}

// Complex password rules
if (values.password) {
if (!/[A-Z]/.test(values.password)) {
errors.password = "Password must contain uppercase letter";
}
if (!/\d/.test(values.password)) {
errors.password = "Password must contain a number";
}
}

return {
errors: Object.keys(errors).length > 0 ? errors : undefined,
isValid: Object.keys(errors).length === 0,
};
};

const form = useForm({
validators: { onChange: customValidator },
});

Validation Timing

El Form gives you fine-grained control over when validation runs:

onChange Validation

Validates as the user types (with debouncing for performance):

const form = useForm({
validators: { onChange: schema },
});

onBlur Validation

Validates when the user leaves a field:

const form = useForm({
validators: { onBlur: schema },
});

onSubmit Validation

Only validates when the form is submitted:

const form = useForm({
validators: { onSubmit: schema },
});

Multiple Validation Stages

You can combine different validators for different stages:

const form = useForm({
validators: {
onChange: basicSchema, // Quick validation while typing
onBlur: detailedSchema, // More thorough validation on blur
onSubmit: serverSchema, // Final validation before submit
},
});

Field-Specific Validation

Sometimes you need different validation logic for individual fields:

const form = useForm({
validators: { onChange: baseSchema },
fieldValidators: {
email: {
// Async check for email uniqueness — runs after sync validators pass
onChangeAsync: async ({ value }) => {
if (!value) return undefined;

const response = await fetch(`/api/check-email?email=${value}`);
const data = await response.json();

// Return an error string, or undefined when valid
return data.exists ? "Email already taken" : undefined;
},
onChangeAsyncDebounceMs: 500,
},
username: {
// Sync field-level validation — return error string or undefined
onChange: ({ value }) =>
/^[a-zA-Z0-9_]+$/.test(value)
? undefined
: "Only letters, numbers, and underscores allowed",
},
},
});
Field validator return shape

Field-level validators (both sync and async) return a string (the error message) or undefined (valid). They do not return { isValid, errors } — that shape is only used internally by schema libraries. Form-level validators in validators that target specific fields return { fields: { <name>: msg } } from async variants; see the Async Validation Guide.

Async Validation

El Form runs async validators on change/blur and awaits them on submit, with stale-result protection so an in-flight result that arrives after the field value has changed is discarded.

Use onChangeAsync / onBlurAsync / onSubmitAsync in fieldValidators for field-level checks. These return a string (error) or undefined (valid):

const form = useForm({
defaultValues: { username: "" },
fieldValidators: {
username: {
onChangeAsync: async ({ value }) => {
if (!value) return undefined;
const res = await fetch(`/api/validate-username?username=${value}`);
const { taken } = await res.json();
return taken ? "Username is already taken" : undefined;
},
onChangeAsyncDebounceMs: 500, // debounce while the user types
},
},
});

For a form-level async submit check, use validators.onSubmitAsync. Return { fields: { <name>: msg } } to attach per-field errors:

const form = useForm({
defaultValues: { email: "" },
validators: {
onSubmitAsync: async ({ value }) => {
const res = await fetch("/api/check-availability", {
method: "POST",
body: JSON.stringify(value),
});
const { emailTaken } = await res.json();
if (emailTaken) {
return { fields: { email: "That email is already registered" } };
}
},
},
});

Submission is blocked while async validators run — submit(), handleSubmit, and trigger() all await the result and a failing async rule prevents the submit handler from being called.

For a deep dive, including asyncAlways and per-event debounce options, see the Async Validation Guide.

Validation State

El Form tracks validation state so you can provide user feedback:

const { formState } = useForm({
validators: { onChange: schema },
});

// Check validation status
console.log(formState.isValid); // true/false
console.log(formState.errors); // Current error state
console.log(formState.touched); // Which fields have been touched

Advanced Patterns

Conditional Validation

Validate fields differently based on other field values:

const conditionalValidator = (values: any) => {
const errors: Record<string, string> = {};

// Only validate shipping address if different from billing
if (values.differentShippingAddress) {
if (!values.shippingStreet) {
errors.shippingStreet = "Shipping street required";
}
if (!values.shippingCity) {
errors.shippingCity = "Shipping city required";
}
}

// Only validate credit card if payment method is card
if (values.paymentMethod === "card") {
if (!values.cardNumber) {
errors.cardNumber = "Card number required";
}
if (!values.expiryDate) {
errors.expiryDate = "Expiry date required";
}
}

return {
errors: Object.keys(errors).length > 0 ? errors : undefined,
isValid: Object.keys(errors).length === 0,
};
};

Cross-Field Validation

Validate relationships between multiple fields:

const crossFieldValidator = (values: any) => {
const errors: Record<string, string> = {};

// Password confirmation
if (values.password !== values.confirmPassword) {
errors.confirmPassword = "Passwords do not match";
}

// Date range validation
if (values.startDate && values.endDate) {
if (new Date(values.startDate) > new Date(values.endDate)) {
errors.endDate = "End date must be after start date";
}
}

// Budget validation
const total = (values.items || []).reduce(
(sum: number, item: any) => sum + item.quantity * item.price,
0
);
if (total > values.budget) {
errors.budget = `Total (${total}) exceeds budget (${values.budget})`;
}

return {
errors: Object.keys(errors).length > 0 ? errors : undefined,
isValid: Object.keys(errors).length === 0,
};
};

Validation with Side Effects

Sometimes validation needs to trigger side effects:

const validatorWithSideEffects = (values: any) => {
const errors: Record<string, string> = {};

// Validation logic
if (!values.email?.includes("@")) {
errors.email = "Invalid email";
}

// Side effect: update other state based on validation
if (values.country === "US" && !values.state) {
errors.state = "State required for US addresses";
}

// Side effect: log validation events
if (Object.keys(errors).length > 0) {
console.log("Validation failed:", errors);
}

return {
errors: Object.keys(errors).length > 0 ? errors : undefined,
isValid: Object.keys(errors).length === 0,
};
};

Best Practices

1. Choose the Right Validation Library

  • Zod: Best for TypeScript projects with complex validation
  • Yup: Good for JavaScript projects or teams familiar with Yup
  • Valibot: Excellent for tree-shaking and functional programming styles
  • Custom functions: Perfect for business logic that schemas can't express

2. Use Appropriate Validation Timing

  • onChange: For immediate feedback on format errors
  • onBlur: For more expensive validation that shouldn't run constantly
  • onSubmit: For final validation and server-side checks

3. Handle Async Validation Carefully

  • Always provide loading states during async validation
  • Debounce async validation to avoid excessive API calls
  • Provide fallback validation for when async validation fails

4. Combine Approaches

Don't be afraid to mix validation approaches:

const form = useForm({
// Schema for basic validation
validators: { onChange: zodSchema },

// Custom functions for business logic
fieldValidators: {
username: { onChangeAsync: checkUsernameAvailability },
email: { onChangeAsync: checkEmailUniqueness },
},
});

Next Steps