React provides multiple ways to handle forms and user input. Understanding when to use each approach is key to building efficient forms.
Using Refs
Refs provide direct access to DOM elements, useful for reading values on submission:
import { useRef } from 'react';
export default function Login() {
const email = useRef();
const password = useRef();
function handleSubmit(event) {
event.preventDefault();
const enteredEmail = email.current.value;
const enteredPassword = password.current.value;
console.log(enteredEmail, enteredPassword);
}
return (
<form onSubmit={handleSubmit}>
<h2>Login</h2>
<div className="control-row">
<div className="control no-margin">
<label htmlFor="email">Email</label>
<input id="email" type="email" name="email" ref={email} />
</div>
<div className="control no-margin">
<label htmlFor="password">Password</label>
<input id="password" type="password" name="password" ref={password} />
</div>
</div>
<p className="form-actions">
<button className="button button-flat">Reset</button>
<button className="button">Login</button>
</p>
</form>
);
}
Refs are ideal when you only need the value on submission and don’t need to validate on every keystroke.
The native FormData API provides an elegant way to extract all form values at once:
function handleSubmit(event) {
event.preventDefault();
const formData = new FormData(event.target);
const data = Object.fromEntries(formData.entries());
// Get multiple values for checkboxes/multi-select
const acquisitionChannel = formData.getAll('acquisition');
console.log(data);
// Reset the form
event.target.reset();
}
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" />
<input name="password" type="password" />
<button>Submit</button>
</form>
);
Make sure each input has a name attribute for FormData to work correctly.
Validation on Keystroke
Validate input as the user types for immediate feedback:
import { useState } from 'react';
function EmailInput() {
const [email, setEmail] = useState('');
const emailIsInvalid = email !== '' && !email.includes('@');
return (
<div className="control">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
{emailIsInvalid && (
<p className="error">Please enter a valid email address.</p>
)}
</div>
);
}
Validation on Blur
Show errors only after the user leaves an input field:
import { useState } from 'react';
function EmailInput() {
const [enteredEmail, setEnteredEmail] = useState('');
const [didEdit, setDidEdit] = useState(false);
const emailIsInvalid = didEdit && !enteredEmail.includes('@');
function handleInputChange(event) {
setEnteredEmail(event.target.value);
setDidEdit(false);
}
function handleInputBlur() {
setDidEdit(true);
}
return (
<div className="control">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={enteredEmail}
onChange={handleInputChange}
onBlur={handleInputBlur}
/>
{emailIsInvalid && (
<p className="error">Please enter a valid email.</p>
)}
</div>
);
}
Validating on every keystroke can be annoying for users. Consider using blur or submission validation for better UX.
Extract input logic into a reusable hook:
import { useState } from 'react';
export function useInput(defaultValue, validationFn) {
const [enteredValue, setEnteredValue] = useState(defaultValue);
const [didEdit, setDidEdit] = useState(false);
const valueIsValid = validationFn(enteredValue);
function handleInputChange(event) {
setEnteredValue(event.target.value);
setDidEdit(false);
}
function handleInputBlur() {
setDidEdit(true);
}
return {
value: enteredValue,
handleInputChange,
handleInputBlur,
hasError: didEdit && !valueIsValid
};
}
Using the Custom Hook
import { useInput } from '../hooks/useInput';
import { isEmail, hasMinLength } from '../util/validation';
function Login() {
const {
value: emailValue,
handleInputChange: handleEmailChange,
handleInputBlur: handleEmailBlur,
hasError: emailHasError
} = useInput('', isEmail);
const {
value: passwordValue,
handleInputChange: handlePasswordChange,
handleInputBlur: handlePasswordBlur,
hasError: passwordHasError
} = useInput('', (value) => hasMinLength(value, 6));
function handleSubmit(event) {
event.preventDefault();
if (emailHasError || passwordHasError) {
return;
}
console.log('Submitting:', { emailValue, passwordValue });
}
return (
<form onSubmit={handleSubmit}>
<div className="control">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={emailValue}
onChange={handleEmailChange}
onBlur={handleEmailBlur}
/>
{emailHasError && <p className="error">Invalid email.</p>}
</div>
<div className="control">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={passwordValue}
onChange={handlePasswordChange}
onBlur={handlePasswordBlur}
/>
{passwordHasError && <p className="error">Password too short.</p>}
</div>
<button>Login</button>
</form>
);
}
Validation Helper Functions
Create reusable validation utilities:
export function isEmail(value) {
return value.includes('@');
}
export function isNotEmpty(value) {
return value.trim() !== '';
}
export function hasMinLength(value, minLength) {
return value.length >= minLength;
}
export function isEqualToOtherValue(value, otherValue) {
return value === otherValue;
}
Refs
Best for: Simple forms, read-on-submitPros: Minimal re-renders, simple codeCons: No real-time validation
State
Best for: Complex validation, controlled inputsPros: Full control, real-time validationCons: More re-renders, more code
FormData
Best for: Standard forms, native validationPros: Clean API, built-in browser supportCons: Less control over validation timing
Source Code: Section 17 - Forms & User Input