There are a lot of form or object schema validation libraries, such as react-hook-form
, formik
, yup
to name a few. In this example we are not going to use any of them.
To start with, we are going to need a state to keep our values. Let's say the following interface describes our values' state.
interface Values {
firstName: string;
password: string;
passwordConfirm: string;
}
And our form component looks like this.
const initialValues: Values = {
firstName: '',
password: '',
passwordConfirm: '',
}
function Form() {
const [values, setValues] = useState<Values>(initialValues);
const handleChange = ({ target }: React.ChangeEvent<HTMLInputElement>) => {
setValues((prev) => ({ ...prev, [target.name]: target.value }));
};
return (
<form>
<label htmlFor="firstName">First name</label>
<input
id="firstName"
name="firstName"
onChange={handleChange}
type="text"
value={values.firstName}
/>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
onChange={handleChange}
type="password"
value={values.password}
/>
<label htmlFor="passwordConfirm">Confirm password</label>
<input
id="passwordConfirm"
name="passwordConfirm"
onChange={handleChange}
type="password"
value={values.passwordConfirm}
/>
</form>
)
}
All we need is an errors object that is calculated based on our current values' state.
const errors = useMemo(() => {
const draft: { [P in keyof Values]?: string } = {};
if (!values.firstName) {
draft.firstName = 'firstName is required';
}
if (!values.password) {
draft.password = 'password is required';
}
if (!values.passwordConfirm) {
draft.passwordConfirm = 'passwordConfirm is required';
}
if (values.password) {
if (values.password.length < 8) {
draft.password = 'password must be at least 8 characters';
}
if (values.passwordConfirm !== values.password) {
draft.passwordConfirm = 'passwordConfirm must match password';
}
}
return draft;
}, [values]);
Then, you'd modify your JSX in order to display the error messages like so.
<label htmlFor="firstName">First name</label>
<input
aria-describedby={
errors.firstName ? 'firstName-error-message' : undefined
}
aria-invalid={!!errors.firstName}
id="firstName"
name="firstName"
onChange={handleChange}
type="text"
value={values.firstName}
/>
{errors.firstName && (
<span id="firstName-error-message">{errors.firstName}</span>
)}
Now the messages appear when we first see the form but that's not the best use experience that we can provide. In order to avoid that there are two ways:
- Display each error after a user interacted with an input
- Display the errors after user submitted the form
With the first approach we would need a touched
state, where we keep the fields that the user touched or to put it otherwise, when a field loses its focus.
const [touched, setTouched] = useState<{ [P in keyof Values]?: true }>({});
const handleBlur = ({ target }: React.FocusEvent<HTMLInputElement>) => {
setTouched((prev) => ({ ...prev, [target.name]: true }));
};
And our field would look like this.
<label htmlFor="firstName">First name</label>
<input
aria-describedby={
touched.firstName && errors.firstName
? 'firstName-error-message'
: undefined
}
aria-invalid={!!touched.firstName && !!errors.firstName}
id="firstName"
name="firstName"
onBlur={handleBlur}
onChange={handleChange}
type="text"
value={values.firstName}
/>
{touched.firstName && errors.firstName && (
<span id="firstName-error-message">{errors.firstName}</span>
)}
In a similar way, we would keep a submitted
state and set it to true
when a user submitted the form for the first time and update our conditions accordingly.
And, that's it!
It may be missing a thing or two, and may require you writing the handlers and the if
statements for calculating the errors, but it's solid solution and a good start to validate forms in React.
Top comments (0)