Skip to main content

useForm Guide

The useForm hook provides powerful, schema-agnostic form state management. It supports any validation approach: Zod, Yup, Valibot, custom functions, or no validation at all.

This guide covers everything you need to know to build custom forms with complete control using the useForm hook.

Quick Start​

Basic Form​

The simplest form with no validation - just state management:

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

function ContactForm() {
const { register, handleSubmit } = useForm({
defaultValues: { email: "", message: "" },
});

return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register("email")} placeholder="Email" />
<textarea {...register("message")} placeholder="Message" />
<button type="submit">Send</button>
</form>
);
}

With Schema Validation​

Add validation using your preferred schema library:

import { z } from "zod";

const schema = z.object({
email: z.string().email("Invalid email"),
age: z.number().min(18, "Must be 18+"),
});

function SignupForm() {
const { register, handleSubmit, formState } = useForm({
validators: { onChange: schema },
defaultValues: { email: "", age: 18 },
});

return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register("email")} />
{formState.errors.email && <p>{formState.errors.email}</p>}

<input {...register("age")} type="number" />
{formState.errors.age && <p>{formState.errors.age}</p>}

<button type="submit" disabled={formState.isSubmitting}>
Submit
</button>
</form>
);
}

Validation Approaches​

Custom Validation Functions​

Sometimes you need validation logic that no schema library provides:

function LoginForm() {
const { register, handleSubmit, formState } = useForm({
validators: {
onChange: ({ values }) => {
const errors = {};

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

if (!values.password || values.password.length < 6) {
errors.password = "Password too short";
}

return Object.keys(errors).length > 0 ? { errors } : { isValid: true };
},
},
defaultValues: { email: "", password: "" },
});

return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register("email")} placeholder="Email" />
{formState.errors.email && <p>{formState.errors.email}</p>}

<input {...register("password")} type="password" placeholder="Password" />
{formState.errors.password && <p>{formState.errors.password}</p>}

<button type="submit">Login</button>
</form>
);
}

Field-Level Validation​

Add specific validation logic for individual fields:

function UserForm() {
const { register, handleSubmit, formState } = useForm({
fieldValidators: {
username: {
onChange: ({ value }) =>
value?.includes("admin")
? { errors: { username: 'Username cannot contain "admin"' } }
: { isValid: true },
},
email: {
onChangeAsync: async ({ value }) => {
if (!value) return { isValid: true };

// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 300));

return value === "taken@example.com"
? { errors: { email: "Email already taken" } }
: { isValid: true };
},
asyncDebounceMs: 500,
},
},
defaultValues: { username: "", email: "" },
});

return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register("username")} placeholder="Username" />
{formState.errors.username && <p>{formState.errors.username}</p>}

<input {...register("email")} placeholder="Email" />
{formState.errors.email && <p>{formState.errors.email}</p>}

<button type="submit">Submit</button>
</form>
);
}

Validation Timing Control​

Control when validation runs with the validators object:

// Validate on form submission only
const form = useForm({
validators: { onSubmit: schema },
defaultValues: { email: "", password: "" },
});

// Validate as user types
const form = useForm({
validators: { onChange: schema },
defaultValues: { email: "", password: "" },
});

// Validate when field loses focus
const form = useForm({
validators: { onBlur: schema },
defaultValues: { email: "", password: "" },
});

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

Core API Methods​

register(fieldName)​

Registers a field and returns props to spread on your input:

const { register } = useForm();

// Basic usage
<input {...register("email")} />

// With additional props
<input
{...register("email")}
placeholder="Enter your email"
className="form-input"
/>

// Different input types
<input {...register("age")} type="number" />
<textarea {...register("bio")} />
<select {...register("country")}>
<option value="us">United States</option>
<option value="ca">Canada</option>
</select>

handleSubmit(onValid, onError?)​

Creates a form submission handler:

const { handleSubmit } = useForm();

const onSubmit = handleSubmit(
(data) => {
console.log("Form is valid:", data);
// Submit to API
},
(errors) => {
console.log("Form has errors:", errors);
// Handle validation errors
}
);

<form onSubmit={onSubmit}>{/* form fields */}</form>;

formState​

Access comprehensive form state information:

const { formState } = useForm();

return (
<div>
{/* Validation state */}
<p>Valid: {formState.isValid}</p>

{/* Change tracking */}
<p>Dirty: {formState.isDirty}</p>
<p>Touched: {JSON.stringify(formState.touched)}</p>

{/* Submission state */}
<p>Submitting: {formState.isSubmitting}</p>
<p>Submitted: {formState.isSubmitted}</p>
<p>Submit successful: {formState.isSubmitSuccessful}</p>
<p>Submit count: {formState.submitCount}</p>

{/* Errors */}
{Object.entries(formState.errors).map(([field, error]) => (
<p key={field}>
Error in {field}: {error}
</p>
))}
</div>
);

Advanced Methods​

setValue(name, value)​

Programmatically set field values:

const { setValue, watch } = useForm();

// Set a single value
setValue("email", "user@example.com");

// Set nested values
setValue("user.profile.name", "John Doe");

// React to changes
const email = watch("email");
useEffect(() => {
if (email === "admin@company.com") {
setValue("role", "admin");
}
}, [email, setValue]);

setError & clearErrors​

Manually manage form errors:

const { setError, clearErrors, formState } = useForm();

// Set field-specific errors
setError("email", "This email is already taken");

// Set general form errors
setError("general", "Something went wrong. Please try again.");

// Clear specific errors
clearErrors("email");

// Clear all errors
clearErrors();

// API error handling
const handleSubmit = async (data) => {
try {
await submitToAPI(data);
} catch (apiError) {
if (apiError.fieldErrors) {
Object.entries(apiError.fieldErrors).forEach(([field, message]) => {
setError(field, message);
});
} else {
setError("general", "Submission failed. Please try again.");
}
}
};

watch​

Subscribe to form value changes:

const { watch } = useForm();

// Watch a single field
const email = watch("email");

// Watch multiple fields
const [email, password] = watch(["email", "password"]);

// watch() is for value paths; read form-level status from formState directly
// (or use useFormSelector for an isolated subscription).
const isFormValid = formState.isValid;

// Use in effects
useEffect(() => {
console.log("Email changed:", email);
}, [email]);

reset​

Reset form to default values:

const { reset } = useForm({
defaultValues: { email: "", name: "" },
});

// Reset to original defaults
reset();

// Reset to new values
reset({ email: "new@example.com", name: "New User" });

// Useful for "Edit" forms
useEffect(() => {
if (userData) {
reset(userData);
}
}, [userData, reset]);

Form State Interface​

Complete FormState interface reference:

interface FormState<T = any> {
// Values and validation
values: Partial<T>; // Current form values
errors: Partial<Record<keyof T, string>>; // Validation errors (string per field)
touched: Partial<Record<keyof T, boolean>>; // Fields user has interacted with
isValid: boolean; // Overall form validity

// Submission state
isSubmitting: boolean; // Form submission in progress
isSubmitted: boolean; // True after the first submit attempt; reset by reset()
isSubmitSuccessful: boolean; // True when the last submit passed validation and the handler ran without throwing; reset by reset()
submitCount: number; // Number of submit attempts; reset by reset()

// Change tracking
isDirty: boolean; // Form has been modified from defaults
}

Per-field dirty/touched maps are exposed as the getDirtyFields() / getTouchedFields() methods — they are not fields on formState.

Best Practices​

1. Choose the Right Validation Timing​

// For user-friendly forms with immediate feedback
const form = useForm({
validators: { onChange: schema },
});

// For less intrusive validation
const form = useForm({
validators: { onBlur: schema },
});

// For traditional form behavior
const form = useForm({
validators: { onSubmit: schema },
});

2. Handle Loading and Error States​

function UserForm({ userId }) {
const [loading, setLoading] = useState(true);
const { reset, handleSubmit, formState } = useForm({
defaultValues: { name: "", email: "" },
});

// Load initial data
useEffect(() => {
async function loadUser() {
try {
const user = await fetchUser(userId);
reset(user);
} catch (error) {
console.error("Failed to load user:", error);
} finally {
setLoading(false);
}
}

if (userId) {
loadUser();
} else {
setLoading(false);
}
}, [userId, reset]);

if (loading) return <div>Loading...</div>;

return (
<form onSubmit={handleSubmit(updateUser)}>
{/* form fields */}
<button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? "Saving..." : "Save"}
</button>
</form>
);
}

3. Type Safety with TypeScript​

interface UserForm {
name: string;
email: string;
age: number;
preferences: {
theme: "light" | "dark";
notifications: boolean;
};
}

const form = useForm<UserForm>({
defaultValues: {
name: "",
email: "",
age: 0,
preferences: { theme: "light", notifications: true },
},
});

// TypeScript knows the correct types
form.setValue("name", "John"); // ✅
form.setValue("age", 25); // ✅
form.setValue("invalid", ""); // ❌ TypeScript error

4. Performance Optimization​

// Memoize expensive computations
const expensiveValidation = useMemo(
() => createComplexValidator(rules),
[rules]
);

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

// Use selective watching
const email = watch("email"); // Only re-renders when email changes
const { name, age } = watch(["name", "age"]); // Only these fields

Integration with UI Libraries​

Material-UI​

import { TextField } from "@mui/material";

function MaterialForm() {
const { register, formState } = useForm();

return (
<form>
<TextField
{...register("email")}
label="Email"
error={!!formState.errors.email}
helperText={formState.errors.email}
fullWidth
/>
</form>
);
}

React Hook Form Migration​

If you're coming from React Hook Form, the API is very similar:

// React Hook Form
const {
register,
handleSubmit,
formState: { errors },
} = useForm();

// El Form - almost identical!
const { register, handleSubmit, formState } = useForm();
// Use formState.errors instead of destructuring

Next Steps​