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";
shouldFocusError?: boolean; // default true
}

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!");
},
});

shouldFocusError

Type: boolean
Optional: Yes — default true

When a submit fails validation, automatically move focus to the first invalid field. This improves keyboard and screen-reader UX (the user lands directly on what needs fixing) and only fires on an invalid submit. Set to false to opt out.

const form = useForm({
validators: { onSubmit: schema },
shouldFocusError: false, // don't auto-focus the first error
});

Focus-on-error relies on the ref that register returns — make sure each field is registered with {...register("name")} so its DOM node can be focused.

validationDebounceMs

Type: number
Optional: Yes — default 0 (no debounce)

Debounce synchronous validation (e.g. an expensive schema, or just to reduce error flicker while typing). Works at both the form and field level, and is the synchronous counterpart to asyncDebounceMs. Set on a ValidatorConfig (either validators or a fieldValidators entry):

const form = useForm({
validators: {
onChange: schema,
validationDebounceMs: 200, // coalesce sync validation while typing
},
});

Error clearing stays immediate — only the setting of a new error is delayed — so a field that becomes valid never lingers with a stale error during the quiet period.

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]);
updateValue

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

Apply a functional update to a field, computed against the latest form state. Unlike setValue (which takes a precomputed value), updateValue passes you the current value and uses your return value — so several updates in the same event handler all apply correctly instead of clobbering each other.

const { updateValue } = useForm({ defaultValues: { items: [] as string[] } });

// Safe even when called multiple times synchronously:
updateValue("items", (prev) => [...prev, "a"]);
updateValue("items", (prev) => [...prev, "b"]); // sees ["a"], appends "b"

Use updateValue instead of setValue whenever the next value depends on the current one (appending to an array, incrementing a counter, toggling). useFieldArray uses it internally so its operations are safe to batch.

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