Robust React Forms
1. The Messy World of React Forms (The Problem)#
When building forms in React normally, you are forced to write massive amounts of repetitive boilerplate. For a standard form with just a few fields, you have to create state hooks for every field, write custom onChange handlers, manage validation errors (using Zod or manually), track API loading state to disable submit buttons, display toast messages, and map server-side validation errors back to inputs. You end up with files where 70% of the code is boring wiring, and only 30% is actual user interface design. This approach is highly error-prone, hard to maintain, and leads to messy components.
// ❌ The Boilerplate Nightmare:
// const [email, setEmail] = useState('');
// const [password, setPassword] = useState('');
// const [isLoading, setIsLoading] = useState(false);
// const [errors, setErrors] = useState({});
//
// You then write custom handers for every field, wrap everything in try/catch,
// and drill prop handlers all the way down to custom styled input components.2. Standard react-hook-form vs. FormWrapper Approach#
If you are already familiar with react-hook-form and Zod, your code will certainly look a bit better than the traditional boilerplate above. You might be using the `useForm` hook, which makes manual state management and validation slightly easier. Even with this approach, you are still forced to explicitly pass `register` and `errors` to every single input component. If your form grows larger or if you want to split components into separate files, prop-drilling quickly creeps back in. To solve this, I designed a centralized, context-driven architecture that decouples form state from the UI elements. The solution is built around a smart container called `FormWrapper`. Instead of drilling props or defining hooks in every component, `FormWrapper` houses the React Hook Form state and Zod validation, then broadcasts this state downward using React Context. Now, child fields (like inputs, checkboxes, select menus) are context-aware. They pull the form state and register themselves automatically using just their 'name' prop. No onChange, no manual values, and zero prop drilling!
// ⚠️ Standard react-hook-form (Better, but still verbose):
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
// ... schema definition
export default function StandardForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema)
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email")} />
{errors.email && <span>{errors.email.message}</span>}
<input {...register("password")} type="password" />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">Submit</button>
</form>
);
}
// ✅ My FormWrapper Approach (The Clean Solution):
// We wrap the form in a single <FormWrapper>.
// Under the hood, any child inputs automatically register themselves via React Context:
// <InputField name="email" label="Email Address" />
// <InputField name="password" label="Password" type="password" />3. How I Build FormWrapper (Under the Hood)#
The FormWrapper component is built using standard React Hook Form providers and a custom mutation context. It accepts a Zod schema for type-safe validation, default values, and an onSubmit function. By wrapping the HTML <form> inside <FormProvider>, all nested inputs can instantly call useFormContext to connect to the state. It also wraps children in <FormMutationContext.Provider> to feed submitting status down to custom buttons.
import React from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { FormMutationContext } from './context-api/FormMutationContext';
export const FormWrapper = <TFormValues extends Record<string, any>, TResponse>({
schema,
onSubmit,
defaultValues,
children,
className = ""
}: FormWrapperProps<TFormValues, TResponse>) => {
const methods = useForm<TFormValues>({
resolver: zodResolver(schema),
defaultValues,
mode: "onTouched",
});
// Propagates methods & mutation states down the React tree
return (
<FormMutationContext.Provider value={mutation}>
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit((data) => mutation.mutate(data))} className={className}>
{children(methods, mutation)}
</form>
</FormProvider>
</FormMutationContext.Provider>
);
};4. Using FormWrapper With React Query (Mutation Mode)#
When integrated with TanStack React Query, FormWrapper becomes incredibly powerful. You pass your mutation function to the onSubmit prop. The wrapper uses useAppMutation internally. When submission starts, the mutation state goes 'pending'. The custom SubmitButton reads this pending status from FormMutationContext automatically—disabling itself and showing a smooth loader spinner without you writing a single line of state code in your page!
import { z } from 'zod';
import { FormWrapper, InputField, SubmitButton } from '@/components/form';
const LoginSchema = z.object({
email: z.string().email('Enter a valid email address'),
password: z.string().min(6, 'Password must be at least 6 characters'),
});
export default function Login() {
return (
<FormWrapper
schema={LoginSchema}
onSubmit={async (data) => authService.login(data)} // API request returned as a promise/mutation
defaultValues={{ email: '', password: '' }}
>
{() => (
<div className="space-y-4">
<InputField name="email" label="Email Address" />
<InputField name="password" label="Password" type="password" />
<SubmitButton>Log In</SubmitButton>
</div>
)}
</FormWrapper>
);
}5. Using FormWrapper Without React Query (Standard Promises)#
What if you don't use React Query? No problem! This architecture is highly flexible. Even in pure promise mode, you still get Zod schema validation and zero prop-drilling auto-registration. To use it without React Query, you can adapt the FormWrapper to run standard promise functions, or let it track an internal isPending state using standard React useState. The nested components and input fields remain exactly the same, making your form code clean, modular, and extremely professional.
// Adapting FormWrapper for pure promises:
export const FormWrapperSimple = <T extends Record<string, any>>({
schema,
onSubmit,
defaultValues,
children
}: SimpleFormProps<T>) => {
const [isPending, setIsPending] = useState(false);
const methods = useForm<T>({ resolver: zodResolver(schema), defaultValues });
const handleFormSubmit = async (data: T) => {
setIsPending(true);
try {
await onSubmit(data); // Simple async/await function!
} finally {
setIsPending(false);
}
};
return (
<FormSimpleContext.Provider value={{ isPending }}>
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(handleFormSubmit)}>
{children(methods, { isPending })}
</form>
</FormProvider>
</FormSimpleContext.Provider>
);
};6. Building a Smart Context-Aware Input (Example Field)#
To understand how the magic happens on the field level, here is a simplified version of the custom `InputField` component. By calling `useFormContext()`, the input directly grabs the global form state. It accesses `register` to wire itself up to the parent form, reads the `errors` state to see if it has validation bugs, and displays errors dynamically—without ever receiving a single form function or error state as a direct prop!
import React from 'react';
import { useFormContext } from 'react-hook-form';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
name: string;
label: string;
}
export const InputField: React.FC<InputProps> = ({ name, label, ...props }) => {
// 1. Pull form state from parent Context
const { register, formState: { errors } } = useFormContext();
const error = errors[name];
return (
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-slate-700">{label}</label>
<input
{...register(name)} // 2. Auto-register with name prop
{...props}
className="w-full p-2.5 rounded-lg border border-slate-200 focus:outline-blue-500"
/>
{/* 3. Display error message automatically */}
{error && (
<span className="text-xs text-red-500 mt-1">
{error.message as string}
</span>
)}
</div>
);
};