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)