React Forms Guide: Controlled Components, Validation, and Libraries
Forms are the primary way users interact with web applications. In React, building forms requires understanding controlled components, validation strategies, and when to reach for a library. React’s declarative nature makes form state explicit but also introduces challenges around performance, re-renders, and input synchronization.
Controlled vs Uncontrolled Components
The fundamental decision in React forms is whether to use controlled or uncontrolled inputs.
Controlled Components
In a controlled component, React state is the single source of truth for the input value. Every keystroke calls a handler that updates state:
function NameForm() {
const [name, setName] = useState('');
return <input value={name} onChange={e => setName(e.target.value)} />;
---The React documentation recommends controlled components for most use cases because the input value is immediately available to validation, formatting, and other UI logic. The trade-off is that controlled components re-render on every keystroke, which can be a performance concern for complex forms with hundreds of inputs.
Uncontrolled Components
Uncontrolled components keep the input value in the DOM, accessed via a ref:
function NameForm() {
const inputRef = useRef();
function handleSubmit() {
console.log(inputRef.current.value);
}
return <input ref={inputRef} />;
---Uncontrolled components are useful when you only need the value at submission time. They reduce re-renders because React does not update state on every keystroke. File inputs are inherently uncontrolled in HTML and work best with this approach.
The React documentation suggests starting with controlled components and switching to uncontrolled only when controlled components cause measurable performance problems.
Mixed Approaches
For hybrid needs — validating one field based on another — controlled components are simpler. Libraries like React Hook Form bridge both worlds by using refs internally while providing a controlled-like API.
Form Validation
Validation is where React forms get complex. The React documentation does not prescribe a validation approach, leaving it to the ecosystem.
Inline Validation with useState
For simple forms, manual validation with useState is sufficient:
const [errors, setErrors] = useState({});
function validate() {
const newErrors = {};
if (!email.includes('@')) newErrors.email = 'Invalid email';
setErrors(newErrors);
---Schema-Based Validation with Yup and Zod
Schema validation libraries provide declarative validation rules. Yup and Zod are the most popular:
import * as yup from 'yup';
const schema = yup.object({
email: yup.string().email().required(),
password: yup.string().min(8).required(),
---);Zod offers TypeScript-first schema inference, which means your validation schema can generate TypeScript types automatically. This eliminates duplication between validation rules and type definitions.
HTML5 Validation
Native HTML5 validation attributes (required, minLength, pattern) work in React but should be a first line of defense, not the sole validation strategy. They provide immediate browser feedback before form submission but are easily bypassed and limited in expressiveness.
React Hook Form
React Hook Form (RHF) is the most popular form library in the React ecosystem. It uses uncontrolled components by default, minimizing re-renders:
function App() {
const { register, handleSubmit, formState: { errors } } = useForm();
return (
<form onSubmit={handleSubmit(data => console.log(data))}>
<input {...register('email', { required: true })} />
{errors.email && <span>Email is required</span>}
</form>
);
---RHF integrates with Zod and Yup via resolvers, supports dynamic fields with useFieldArray, and provides performance comparable to vanilla React forms. Its call useForm does not cause re-renders when fields change unless you subscribe to the specific field’s state. The RHF documentation benchmarks show that RHF reduces re-renders by up to 80% compared to Formik.
useFieldArray for Dynamic Forms
RHF’s useFieldArray hook handles dynamic lists of fields — adding and removing items, reordering, and maintaining validation:
const { fields, append, remove } = useFieldArray({ control, name: "items" });This is essential for use cases like invoice line items, survey questions, or tag inputs.
Formik
Formik is the older, more established form library. It uses controlled components and provides a Field component that automatically connects to Formik state:
<Formik initialValues={{ email: '' }} validationSchema={schema} onSubmit={...}>
{() => <Form><Field name="email" type="email" /><ErrorMessage name="email" /></Form>}
</Formik>Formik has a larger API surface and more features out of the box. It includes rich array support, field-level validation, and extensive TypeScript types. The trade-off is performance — Formik re-renders more aggressively than React Hook Form, which can lead to lag in forms with dozens or hundreds of fields.
When to Choose Formik
Choose Formik when you need extensive built-in features, your team is already familiar with it, or you prefer a more opinionated API. Formik’s documentation includes guides for integrating with Material-UI, Chakra UI, and other component libraries.
Zod and TypeScript Integration
Zod has become the validation library of choice for TypeScript projects. Its z.infer extracts TypeScript types from schemas:
const UserSchema = z.object({ email: z.string().email() });
type User = z.infer<typeof UserSchema>; // { email: string }
Combined with React Hook Form’s zodResolver, you get end-to-end type safety from form validation through to the submission handler.
Multi-Step Forms and Wizard Patterns
Complex forms often span multiple steps or pages. Implementing multi-step forms with React requires managing the current step, persisting data across steps, and validating each step before advancing.
function MultiStepForm() {
const [step, setStep] = useState(1);
const [data, setData] = useState({});
function nextStep() {
setStep(s => Math.min(s + 1, 3));
}
return (
<form onSubmit={handleSubmit}>
{step === 1 && <PersonalInfo data={data} onChange={setData} />}
{step === 2 && <AddressInfo data={data} onChange={setData} />}
{step === 3 && <Review data={data} />}
{step > 1 && <button type="button" onClick={() => setStep(s => s - 1)}>Back</button>}
{step < 3 ? <button type="button" onClick={nextStep}>Next</button> : <button type="submit">Submit</button>}
</form>
);
---React Hook Form’s useForm with currentStep tracking handles multi-step forms elegantly, persisting field values across steps and validating only the current step’s fields. The RHF documentation includes a multi-step form wizard example.
Accessible Forms
Accessibility in forms is not optional. Every input needs an associated label, error messages must be announced by screen readers, and focus management must be predictable:
function AccessibleField({ label, error, children }) {
const id = useId();
const errorId = `${id}-error`;
return (
<div role="group">
<label htmlFor={id}>{label}</label>
{React.cloneElement(children, { id, 'aria-describedby': error ? errorId : undefined, 'aria-invalid': !!error })}
{error && <span id={errorId} role="alert">{error}</span>}
</div>
);
---The useId hook generates unique IDs consistently across server and client rendering, preventing hydration mismatches. The role="alert" on error messages ensures they are announced by screen readers immediately.
File Uploads
File inputs are inherently uncontrolled in React. Handling file uploads requires accessing the files from the input’s files property:
function FileUpload() {
const [files, setFiles] = useState([]);
return (
<input
type="file"
multiple
onChange={e => setFiles([...e.target.files])}
/>
);
---For upload progress and previews, combine with FormData and fetch or libraries like react-dropzone for drag-and-drop support. The Fetch API’s XMLHttpRequest or the newer fetch with ReadableStream provide upload progress tracking.
Performance Optimization for Large Forms
Forms with hundreds of fields (surveys, data entry dashboards) require specific optimization strategies:
Field-Level Subscriptions
React Hook Form’s default behavior isolates re-renders to individual fields. When one field changes, only that field’s component re-renders, not the entire form:
// Only Field subscribes to its own value
<Controller
name="email"
control={control}
render={({ field }) => <input {...field} />}
/>Debounced Validation
For expensive validation (API calls, complex computations), debounce the validation handler:
const debouncedValidate = useMemo(
() => debounce((value) => checkUsernameAvailability(value), 500),
[]
);React Hook Form vs Formik Performance
RHF benchmarks consistently show 2-3x faster re-render performance on large forms compared to Formik, because RHF isolates field subscriptions at the input level rather than the form level.
FAQ
Should I use a form library or build my own?
For simple forms (login, contact), React’s built-in controlled components are sufficient. For complex forms with validation, dynamic fields, and multi-step flows, use React Hook Form or Formik. Building your own form state management is rarely worth the effort.
What is the difference between React Hook Form and Formik?
React Hook Form uses uncontrolled components and minimizes re-renders, making it faster for large forms. Formik uses controlled components and has a more declarative API. RHF has a smaller bundle size (~10 KB vs ~30 KB for Formik).
How do I handle async validation?
Both RHF and Formik support async validation. In RHF, return a promise from the validate function in the register options. In Formik, use the validate prop on <Formik>.
Can I use Zod with React Hook Form?
Yes. Install @hookform/resolvers and use zodResolver to connect Zod schemas to RHF. This is the recommended approach for TypeScript projects.
Conclusion
React forms require understanding the controlled vs uncontrolled distinction, choosing a validation strategy, and picking the right library for your scale. Start with controlled components and React Hook Form for validation. Add Zod or Yup for schema-based validation as complexity grows. The React Hook Form documentation at react-hook-form.com provides interactive examples, and Formik’s guide at formik.org covers advanced patterns like multi-step forms and wizard components. For state management in forms, see React State Management, and for integrating forms with routing (creating, editing resources), see React Router Guide.
For a comprehensive overview, read our article on Next Js Guide.
For a comprehensive overview, read our article on React Authentication.