Skip to main content

useForm API

The useForm hook is the core of El Form, providing comprehensive form state management with schema-agnostic validation support.

Function Signature

function useForm<T extends Record<string, any>>(
options: UseFormOptions<T>
): UseFormReturn<T>;

Parameters

UseFormOptions<T>

interface UseFormOptions<T extends Record<string, any>> {
defaultValues?: Partial<T>;
validators?: ValidatorConfig;
onSubmit?: (values: T) => void | Promise<void>;
fieldValidators?: Partial<Record<keyof T, ValidatorConfig>>;
fileValidators?: Partial<Record<keyof T, FileValidationOptions>>;
mode?: "onChange" | "onBlur" | "onSubmit" | "all";
validateOn?: "onChange" | "onBlur" | "onSubmit" | "manual";
}

defaultValues

Type: Partial<T>
Optional: Yes
Default: {}

Initial values for form fields.

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

validators

Type: ValidatorConfig
Optional: Yes

Form-level validation configuration. Supports multiple validation libraries and custom functions.

// With Zod
import { z } from "zod";
const form = useForm({
validators: {
onChange: z.object({
email: z.string().email(),
password: z.string().min(8),
}),
},
});

// With custom functions
const form = useForm({
validators: {
onChange: (values) => {
const errors = {};
if (!values.email?.includes("@")) {
errors.email = "Invalid email";
}
return Object.keys(errors).length > 0 ? { errors } : { isValid: true };
},
},
});

// Multiple validation stages
const form = useForm({
validators: {
onChange: quickValidation,
onBlur: detailedValidation,
onSubmit: serverValidation,
},
});

fieldValidators

Type: Partial<Record<keyof T, ValidatorConfig>>
Optional: Yes

Field-specific validation that runs in addition to form-level validation.

const form = useForm({
fieldValidators: {
username: {
onChange: (value) =>
value?.includes("admin")
? { errors: { username: 'Username cannot contain "admin"' } }
: { isValid: true },
onChangeAsync: async (value) => {
const available = await checkUsername(value);
return available
? { isValid: true }
: { errors: { username: "Username taken" } };
},
asyncDebounceMs: 500,
},
},
});

fileValidators

Type: Partial<Record<keyof T, FileValidationOptions>>
Optional: Yes

File-specific validation for file upload fields.

const form = useForm({
fileValidators: {
avatar: {
maxSize: 5 * 1024 * 1024, // 5MB
allowedTypes: ["image/jpeg", "image/png"],
maxFiles: 1,
},
documents: {
maxSize: 10 * 1024 * 1024, // 10MB
allowedTypes: ["application/pdf", "application/msword"],
maxFiles: 5,
},
},
});

mode

Type: "onChange" | "onBlur" | "onSubmit" | "all"
Optional: Yes
Default: "onSubmit"

Legacy validation mode setting. Use validateOn for more precise control.

validateOn

Type: "onChange" | "onBlur" | "onSubmit" | "manual"
Optional: Yes

When validation should run:

  • "onChange" - Validate on every input change
  • "onBlur" - Validate when field loses focus
  • "onSubmit" - Validate only on form submission
  • "manual" - Only validate when explicitly triggered

onSubmit

Type: (values: T) => void | Promise<void>
Optional: Yes

Default submit handler. Can be overridden by handleSubmit.

const form = useForm({
onSubmit: async (data) => {
await saveUser(data);
console.log("User saved!");
},
});

Return Value

UseFormReturn<T>

The useForm hook returns an object with the following properties and methods:

Core Methods

register

Type:

register<Name extends Path<T>>(name: Name): RegisterReturn<PathValue<T, Name>>

Registers a field with the form and returns props to spread on input elements.

const { register } = useForm();

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

// With TypeScript and field name validation
<input {...register('email')} type="email" placeholder="Email" />
<textarea {...register('bio')} placeholder="Tell us about yourself" />
<input {...register('age')} type="number" min={0} max={120} />

// Checkbox fields
<input {...register('terms')} type="checkbox" />

// File fields
<input {...register('avatar')} type="file" accept="image/*" />

Return Type (conditional for typed paths):

{
name: string;
onChange: (e: React.ChangeEvent<any>) => void;
onBlur: (e: React.FocusEvent<any>) => void;
} & (
| { checked: boolean; value?: never; files?: never } // Checkbox
| { value: any; checked?: never; files?: never } // Text/Number/etc
| { files: FileList | File | File[] | null; value?: never; checked?: never } // File
)

Note: register is strictly typed. It only accepts valid paths defined by your form type T. Array paths are supported (e.g., skills.0.name or template strings like skills.${i}.name) as long as they resolve to valid paths. Invalid paths will produce TypeScript errors.

Typed usage examples
interface ProfileForm {
name: string;
terms: boolean;
avatar: File | null;
}

const { register } = useForm<ProfileForm>({
defaultValues: { name: "", terms: false, avatar: null },
});

// Boolean fields → `checked`
<input type="checkbox" {...register("terms")} />
// ^ returns { checked: boolean, name, onChange, onBlur }

// File fields → `files`
<input type="file" {...register("avatar")} />
// ^ returns { files: File | FileList | File[] | null, name, onChange, onBlur }

// Text/number/etc → `value`
<input type="text" {...register("name")} />
// ^ returns { value: string, name, onChange, onBlur }

// Array paths (including template strings) are supported when they resolve
// to valid paths in your form type. Invalid paths will error at compile time.
handleSubmit

Type: (onValid: (data: T) => void, onError?: (errors: Record<keyof T, string>) => void) => (e: React.FormEvent) => void

Creates a form submission handler with validation.

const { handleSubmit } = useForm();

// Basic usage
const onSubmit = handleSubmit(
(data) => console.log("Success:", data),
(errors) => console.log("Errors:", errors)
);

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

// Async submission
const onSubmit = handleSubmit(async (data) => {
try {
await submitToAPI(data);
showSuccessMessage();
} catch (error) {
showErrorMessage(error.message);
}
});

// With error handling
const onSubmit = handleSubmit(
(data) => saveData(data),
(errors) => {
// Handle validation errors
Object.entries(errors).forEach(([field, message]) => {
showFieldError(field, message);
});
}
);

Form State

formState

Type: FormState<T>

Current form state object.

interface FormState<T> {
values: Partial<T>; // Current form values
errors: Partial<Record<keyof T, string>>; // Validation errors
touched: Partial<Record<keyof T, boolean>>; // Fields user has interacted with
isSubmitting: boolean; // Form submission in progress
isValid: boolean; // Overall form validity
isDirty: boolean; // Form has been modified
}

Usage:

const { formState } = useForm();

// Access form state
console.log(formState.values); // Current field values
console.log(formState.errors); // Current validation errors
console.log(formState.touched); // Which fields have been touched
console.log(formState.isValid); // Is the entire form valid?
console.log(formState.isDirty); // Has the form been modified?
console.log(formState.isSubmitting); // Is submission in progress?

// Conditional rendering based on state
{
formState.errors.email && formState.touched.email && (
<span className="error">{formState.errors.email}</span>
);
}

{
formState.isSubmitting && <LoadingSpinner />;
}

<button type="submit" disabled={!formState.isValid || formState.isSubmitting}>
{formState.isSubmitting ? "Saving..." : "Save"}
</button>;

Value Management

setValue

Type: <Name extends Path<T>>(path: Name, value: PathValue<T, Name>) => void

Set the value of a specific field, including nested fields.

const { setValue } = useForm();

// Set simple field values
setValue("email", "user@example.com");
setValue("age", 25);

// Set nested field values
setValue("profile.name", "John Doe");
setValue("preferences.theme", "dark");

// Set array values
setValue("hobbies.0", "reading");
setValue("users.2.email", "admin@example.com");

// Programmatic form updates
useEffect(() => {
if (userRole === "admin") {
setValue("permissions", ["read", "write", "admin"]);
}
}, [userRole, setValue]);
setValues

Type: (values: Partial<T>) => void

Set multiple field values at once.

const { setValues } = useForm();

// Set multiple values
setValues({
name: "John Doe",
email: "john@example.com",
age: 30,
});

// Merge with existing values
setValues({
preferences: {
theme: "dark",
notifications: false,
},
});

// Load data from API
useEffect(() => {
async function loadUserData() {
const userData = await fetchUser(userId);
setValues(userData);
}
loadUserData();
}, [userId, setValues]);
watch

Type: Overloaded function for watching form values

Watch form values and re-render when they change.

const { watch } = useForm();

// Watch all values
const allValues = watch();

// Watch specific field
const email = watch("email");

// Watch multiple fields
const [name, age] = watch(["name", "age"]);

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

// Conditional logic based on watched values
const country = watch("country");
const showStateField = country === "US";

// Watch for form changes
const values = watch();
useEffect(() => {
console.log("Form changed:", values);
}, [values]);

Reset Operations

reset

Type: (options?: ResetOptions<T>) => void

Reset the form to its default state or new values.

interface ResetOptions<T> {
values?: Partial<T>;
keepErrors?: boolean;
keepDirty?: boolean;
keepTouched?: boolean;
}

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

// Reset to original defaults
reset();

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

// Reset but keep certain state
reset({
values: newValues,
keepTouched: true, // Don't reset touched state
});

// Reset form after successful submission
const handleSubmit = async (data) => {
await saveData(data);
reset(); // Clear form
};

// Reset specific aspects
reset({ keepErrors: true }); // Reset values but keep errors
resetValues

Type: (values?: Partial<T>) => void

Reset only form values without affecting errors or touched state.

const { resetValues } = useForm();

// Reset all values to defaults
resetValues();

// Reset to specific values
resetValues({ name: "Default Name", email: "" });
resetField

Type: <Name extends Path<T>>(name: Name) => void

Reset a specific field to its default value.

const { resetField } = useForm();

// Reset individual fields
resetField("email");
resetField("password");

// Reset field on error
if (apiError) {
resetField("password"); // Clear password on login error
}

Field State Queries

getFieldState

Type: <Name extends keyof T>(name: Name) => FieldState

Get detailed state information for a specific field.

interface FieldState {
isDirty: boolean;
isTouched: boolean;
error?: string;
}

const { getFieldState } = useForm();

const emailState = getFieldState("email");
console.log(emailState.isDirty); // Has field been modified?
console.log(emailState.isTouched); // Has field been interacted with?
console.log(emailState.error); // Current field error
isDirty

Type: <Name extends keyof T>(name?: Name) => boolean

Check if the form or a specific field has been modified.

const { isDirty } = useForm();

// Check if entire form is dirty
const formIsDirty = isDirty();

// Check if specific field is dirty
const emailIsDirty = isDirty("email");

// Show unsaved changes warning
useEffect(() => {
const handleBeforeUnload = (e) => {
if (isDirty()) {
e.preventDefault();
e.returnValue = "You have unsaved changes";
}
};

window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [isDirty]);
Field State Utilities
const form = useForm();

// Get dirty and touched fields
const dirtyFields = form.getDirtyFields(); // { email: true, name: false }
const touchedFields = form.getTouchedFields(); // { email: true, password: true }

// Individual field checks
const isEmailDirty = form.isFieldDirty("email");
const isEmailTouched = form.isFieldTouched("email");
const isEmailValid = form.isFieldValid("email");

// Form-wide checks
const hasAnyErrors = form.hasErrors();
const errorCount = form.getErrorCount();

Touched State Management

markAllTouched

Type: () => void

Mark all fields as touched (useful for showing all errors).

const { markAllTouched } = useForm();

// Show all validation errors
const handleShowAllErrors = () => {
markAllTouched();
};

// Mark all touched before submit
const handleSubmit = (data) => {
markAllTouched(); // Show all errors if validation fails
// ... submit logic
};
markFieldTouched / markFieldUntouched

Type: (name: string) => void

Mark specific fields as touched or untouched.

const { markFieldTouched, markFieldUntouched } = useForm();

// Mark field as touched
markFieldTouched("email");

// Mark field as untouched (hide errors)
markFieldUntouched("email");

// Touch field programmatically
const handleFieldFocus = (fieldName) => {
markFieldTouched(fieldName);
};

Error Management

setError

Type: <Name extends keyof T>(name: Name, error: string) => void

Manually set an error for a specific field.

const { setError } = useForm();

// Set field-specific errors
setError("email", "This email is already taken");
setError("username", "Username must be unique");

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

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

Type: (name?: keyof T) => void

Clear errors for a specific field or all fields.

const { clearErrors } = useForm();

// Clear specific field error
clearErrors("email");

// Clear all errors
clearErrors();

// Clear errors when user starts fixing them
const email = watch("email");
useEffect(() => {
if (email) {
clearErrors("email");
}
}, [email, clearErrors]);
trigger

Type: Overloaded function for manual validation

Manually trigger validation for fields.

const { trigger } = useForm();

// Validate all fields
const isFormValid = await trigger();

// Validate specific field
const isEmailValid = await trigger("email");

// Validate multiple fields
const areFieldsValid = await trigger(["email", "password"]);

// Trigger validation on blur
const handleEmailBlur = async () => {
const isValid = await trigger("email");
if (!isValid) {
setFocus("email");
}
};

// Validate before proceeding to next step
const handleNextStep = async () => {
const isCurrentStepValid = await trigger(["email", "password"]);
if (isCurrentStepValid) {
setCurrentStep(currentStep + 1);
}
};

Focus Management

setFocus

Type: <Name extends keyof T>(name: Name, options?: SetFocusOptions) => void

Set focus to a specific field.

interface SetFocusOptions {
shouldSelect?: boolean;
}

const { setFocus } = useForm();

// Focus field
setFocus("email");

// Focus and select text
setFocus("email", { shouldSelect: true });

// Focus first error field
const handleSubmit = async (data) => {
const isValid = await trigger();
if (!isValid) {
const firstErrorField = Object.keys(formState.errors)[0];
setFocus(firstErrorField);
}
};

// Focus field after async validation
const validateEmail = async () => {
const isValid = await trigger("email");
if (!isValid) {
setFocus("email");
}
};

Array Operations

addArrayItem

Type: (path: string, item: any) => void

Add an item to an array field.

const { addArrayItem } = useForm();

// Add item to array
addArrayItem("hobbies", "reading");
addArrayItem("users", { name: "", email: "" });

// Add item to nested array
addArrayItem("profile.skills", "JavaScript");

// Dynamic list management
const handleAddUser = () => {
addArrayItem("users", {
name: "",
email: "",
role: "user",
});
};
removeArrayItem

Type: (path: string, index: number) => void

Remove an item from an array field.

const { removeArrayItem } = useForm();

// Remove item by index
removeArrayItem("hobbies", 0);
removeArrayItem("users", 2);

// Remove item from nested array
removeArrayItem("profile.skills", 1);

// Dynamic list management
const handleRemoveUser = (index) => {
removeArrayItem("users", index);
};

Advanced Form Control

submit

Type: () => Promise<void>

Submit the form programmatically (bypasses form element submission).

const { submit } = useForm();

// Submit form programmatically
const handleSave = async () => {
await submit();
};

// Submit with custom logic
const handleSaveAndContinue = async () => {
try {
await submit();
navigateToNextPage();
} catch (error) {
console.error("Submission failed:", error);
}
};
submitAsync

Type: () => Promise<{ success: true; data: T } | { success: false; errors: Partial<Record<keyof T, string>> }>

Submit the form and return detailed result information.

const { submitAsync } = useForm();

const handleSubmit = async () => {
const result = await submitAsync();

if (result.success) {
console.log("Form submitted successfully:", result.data);
showSuccessMessage();
} else {
console.log("Validation errors:", result.errors);
showErrorSummary(result.errors);
}
};
canSubmit

Type: boolean

Computed property indicating whether the form can be submitted.

const { canSubmit } = useForm();

// Enable/disable submit button
<button type="submit" disabled={!canSubmit}>
Submit
</button>;

// Conditional submission logic
const handleKeyPress = (e) => {
if (e.key === "Enter" && canSubmit) {
handleSubmit();
}
};

Form History & Persistence

getSnapshot

Type: () => FormSnapshot<T>

Get a snapshot of the current form state for history/undo functionality.

interface FormSnapshot<T> {
values: Partial<T>;
errors: Partial<Record<keyof T, string>>;
touched: Partial<Record<keyof T, boolean>>;
timestamp: number;
isDirty: boolean;
}

const { getSnapshot } = useForm();

// Save form state
const snapshot = getSnapshot();
localStorage.setItem("formBackup", JSON.stringify(snapshot));

// Create undo functionality
const [history, setHistory] = useState([]);

const saveToHistory = () => {
const snapshot = getSnapshot();
setHistory((prev) => [...prev, snapshot]);
};
restoreSnapshot

Type: (snapshot: FormSnapshot<T>) => void

Restore form state from a snapshot.

const { restoreSnapshot } = useForm();

// Restore from localStorage
const savedSnapshot = localStorage.getItem("formBackup");
if (savedSnapshot) {
restoreSnapshot(JSON.parse(savedSnapshot));
}

// Implement undo
const handleUndo = () => {
if (history.length > 0) {
const lastSnapshot = history[history.length - 1];
restoreSnapshot(lastSnapshot);
setHistory((prev) => prev.slice(0, -1));
}
};
hasChanges / getChanges

Type: () => boolean / () => Partial<T>

Check for changes or get changed values since form initialization.

const { hasChanges, getChanges } = useForm();

// Check if form has any changes
const unsavedChanges = hasChanges();

// Get only the changed values
const changedValues = getChanges();
console.log("Changed fields:", changedValues);

// Show unsaved changes warning
useEffect(() => {
if (hasChanges()) {
const handler = (e) => {
e.preventDefault();
e.returnValue = "You have unsaved changes";
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}
}, [hasChanges]);

File Operations

File Management Methods
const form = useForm();

// Add file to field
form.addFile("avatar", selectedFile);

// Remove file from field
form.removeFile("avatar", 0); // Remove by index
form.removeFile("avatar"); // Remove all files

// Clear all files from field
form.clearFiles("avatar");

// Get file information
const fileInfo = form.getFileInfo(file);
console.log(fileInfo.size, fileInfo.type, fileInfo.lastModified);

// Get file preview URL
const previewUrl = form.getFilePreview(file);

// Access file previews
console.log(form.filePreview); // { avatar: 'blob:...', document: 'blob:...' }

Usage Examples

Basic Form

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

const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});

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

const onSubmit = handleSubmit(
(data) => console.log("Login successful:", data),
(errors) => console.log("Validation errors:", errors)
);

return (
<form onSubmit={onSubmit}>
<div>
<input {...register("email")} placeholder="Email" />
{formState.errors.email && (
<span className="error">{formState.errors.email}</span>
)}
</div>

<div>
<input
{...register("password")}
type="password"
placeholder="Password"
/>
{formState.errors.password && (
<span className="error">{formState.errors.password}</span>
)}
</div>

<button
type="submit"
disabled={!formState.isValid || formState.isSubmitting}
>
{formState.isSubmitting ? "Signing in..." : "Sign In"}
</button>
</form>
);
}

Advanced Form with Async Validation

function RegistrationForm() {
const { register, handleSubmit, formState, setError, clearErrors } = useForm({
validators: { onChange: registrationSchema },
fieldValidators: {
username: {
onChangeAsync: async (value) => {
if (!value) return { isValid: true };

const available = await checkUsernameAvailability(value);
return available
? { isValid: true }
: { errors: { username: "Username already taken" } };
},
asyncDebounceMs: 500,
},
},
defaultValues: {
username: "",
email: "",
password: "",
confirmPassword: "",
},
});

const onSubmit = handleSubmit(async (data) => {
try {
await registerUser(data);
console.log("Registration successful!");
} catch (apiError) {
if (apiError.fieldErrors) {
Object.entries(apiError.fieldErrors).forEach(([field, message]) => {
setError(field, message);
});
}
}
});

return (
<form onSubmit={onSubmit}>
<div>
<input {...register("username")} placeholder="Username" />
{formState.isValidating && <span>Checking availability...</span>}
{formState.errors.username && (
<span className="error">{formState.errors.username}</span>
)}
</div>

{/* Other fields... */}

<button
type="submit"
disabled={!formState.isValid || formState.isSubmitting}
>
{formState.isSubmitting ? "Creating Account..." : "Create Account"}
</button>
</form>
);
}

TypeScript Integration

Type-Safe Form Definition

interface UserProfile {
personal: {
firstName: string;
lastName: string;
email: string;
};
preferences: {
theme: "light" | "dark";
notifications: boolean;
};
hobbies: string[];
}

const form = useForm<UserProfile>({
defaultValues: {
personal: { firstName: "", lastName: "", email: "" },
preferences: { theme: "light", notifications: true },
hobbies: [],
},
});

// TypeScript provides full autocomplete and type checking
form.setValue("personal.firstName", "John"); // ✅ Type-safe
form.setValue("personal.age", 25); // ❌ TypeScript error
form.register("preferences.theme"); // ✅ Type-safe

Generic Form Component

interface FormProps<T extends Record<string, any>> {
schema: z.ZodType<T>;
defaultValues: Partial<T>;
onSubmit: (data: T) => void;
}

function GenericForm<T extends Record<string, any>>({
schema,
defaultValues,
onSubmit,
}: FormProps<T>) {
const form = useForm<T>({
validators: { onChange: schema },
defaultValues,
});

return (
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* Render fields based on schema */}
</form>
);
}

Best Practices

1. Use TypeScript for Type Safety

// Define your form data interface
interface ContactForm {
name: string;
email: string;
message: string;
}

// Use it with useForm for full type safety
const form = useForm<ContactForm>({
defaultValues: { name: "", email: "", message: "" },
});

2. Optimize Re-renders with Selector Subscriptions

import {
FormProvider,
useFormSelector,
useField,
shallowEqual,
} from "el-form-react-hooks";

const form = useForm<{ items: Array<{ id: number }>; email: string }>({
defaultValues: { items: [{ id: 1 }], email: "" },
});

// Subscribe to a specific field's slice (value/error/touched)
function EmailField() {
const { value, error, touched } = useField<any, any>("email" as any);
const props = form.register("email");
return (
<div>
<input {...(props as any)} />
{touched && error && <span className="error">{error}</span>}
</div>
);
}

// Subscribe to a selected slice with custom equality
function ItemsList() {
const items = useFormSelector((s) => s.values.items ?? [], shallowEqual);
return (
<ul>
{items.map((it) => (
<li key={it.id}>{it.id}</li>
))}
</ul>
);
}

function App() {
return (
<FormProvider form={form}>
<EmailField />
<ItemsList />
</FormProvider>
);
}

Notes:

  • useField(path) re-renders only when that field’s value, error, or touched changes.
  • useFormSelector(selector, equality?) re-renders when the selector result changes; pass shallowEqual for arrays/objects to avoid unnecessary updates.
  • SSR: the server snapshot matches the client’s initial selector result to avoid hydration mismatches.

3. Handle Loading States Properly

const form = useForm();

// Show loading during async operations
{
form.formState.isSubmitting && <LoadingSpinner />;
}
{
form.formState.isValidating && <span>Validating...</span>;
}

// Disable form during submission
<fieldset disabled={form.formState.isSubmitting}>{/* form fields */}</fieldset>;

4. Implement Proper Error Handling

const handleSubmit = async (data) => {
try {
await submitData(data);
} catch (error) {
if (error.fieldErrors) {
// Handle field-specific errors
Object.entries(error.fieldErrors).forEach(([field, message]) => {
setError(field, message);
});
} else {
// Handle general errors
setError("general", error.message || "Something went wrong");
}
}
};

See Also