Build a form validation engine using custom React Hooks, from scratch, without having to learn a single form library. Read on to learn how!

In part one, Simplify Forms using Custom React Hooks, we abstracted away all of the form event handler logic into a custom React Hook. As a result, the code in our form components was reduced by a significant amount.

After publishing last week’s tutorial, I had a number of readers ask how I’d use React Hooks to solve two common problems related to forms:

  • Initializing the form values
  • Validation

Therefore, I’ll be answering these questions in this tutorial. So, let’s begin learning how to initialize form values and handle form validation using React Hooks!

What We’re Building

A login form with validation using a custom react hook.

We’ll be using the same project from part one. If you haven’t yet gone through the first tutorial on how to Simplify Forms with Custom React Hooks. Or, you can grab the full code and continue with this tutorial.

There’s plenty of form libraries available for React. They do a great job of simplifying your code. However, by using a library, you’re adding to the (already long) list of dependencies your project relies on.

Libraries are also opinionated. You have to learn how that library works, as well as its limitations.

The goal of this tutorial is to walk you through writing your own custom React Hook that handles form validation for you. We’re going to start with initialization.

Initializing the Form Values

Actually, handling form initialization doesn’t require our custom React Hook, useForm, but it’s still an important part of the validation process.

Start by opening up the original project in your text editor, open Form.js, and take a look at the HTML that’s being returned towards the bottom of the component, specifically the email input field:

Form.js
<input className="input" type="email" name="email" onChange={handleChange} value={values.email} required />

Let’s take a closer look at the value attribute. We pass in the email key returned from the values object that’s stored in the useForm custom Hook.

In the React world, because we’re handling the input’s value ourselves, this means our email input field is a controlled input.

Well, not exactly.

The email input does become a controlled input, eventually, when we pass a real value to it.

When the Form component first renders, it initializes the useForm custom React Hook. Go ahead and open up useForm.js and look at the initial state of values inside of that custom Hook:

useForm.js
const [values, setValues] = useState({});

We’re initializing the values state to an empty object. As a result, when our Form component gets values.email, it doesn’t find it inside values and therefore is undefined.

So, our email input field starts off with a value of undefined, but when we type a value inside of the input, useForm finally sets the value of email inside of its state to be a non-undefined value.

That’s not great. What we’re doing is switching from an uncontrolled input to a controlled input.

We get a big ๐Ÿ‘Ž from React for doing that. Bad developer, bad!

|| to the Rescue

We’ve all seen, and perhaps even used the operator above, ‘||‘, inside of a conditional statement. That’s right, it’s OR.

Therefore, we’re going to use the OR operator to set the default value of the email input, like so:

Form.js
<input ... value={values.email || ''} ... />

I sometimes think it’s helpful to explain code in plain English. So, what we’re saying above is: set the value of this input to be values.email OR if values.email doesn’t exist, set it to be an empty string.

In other words, we initialize the default value of the input to an empty string. I want to add that we’re not limited to using an empty string. If it’s a number input, we’d use 0, for example.

Setting Up Form Validation Using React Hooks

Now that we’ve tackled initializing the form values, let’s move on to extending our custom React Hook to handle form validation.

We need to do several things in order to validate a form:

  • Define validation rules for the form
  • Store any errors in a state variable
  • Prevent the form from submitting if any errors exist

Defining the Validation Rules

Start by creating a new file for us to define rules for our email and password fields.

Each form will have a list of rules that are specific to its input fields, so name the new file something specific, like LoginFormValidationRules.js.

Add a single function called validate which takes one parameter, values, export it as the default value, and initialize a new object inside of the validate function called errors.

we’ll return the error object at the end of the function so we can enumerate over the errors inside of the useForm custom Hook.

LoginFormValidationRules.js
export default function validate(values) { let errors = {}; return errors; };

Let’s add a validation rule for the email input field. The first rule, that’s likely going to apply to every required field in your form, will be to check that the value actually exists.

LoginFormValidationRules.js
export default function validate(values) { let errors = {}; if (!values.email) { errors.email = 'Email address is required'; } return errors; };

Because we’re building an object of errors, we actually check if the email value does not exist, and if so, then we add a new key to the error object called email. Finally we set its value to be ‘Email address is required’.

For an email to be correct however, it has to be written in a specific way, Usually something@something.com.

How can we easily check that the email address is typed in the correct format?

I’m going to say a phrase that makes even the most hardened developer shudder with dread, but please, hear me out.

RegEx.

๐Ÿ˜ฑ

If you’re like me, you won’t ever learn how to write a regular expression, and instead search for one online like a normal developer. A great site is RegExLib.com, which has thousands of useful examples.

After the end of the first if clause, add an else if clause that tests the value of email against a regular expression.

LoginFormValidationRules.js
export default function validate(values) { let errors = {}; if (!values.email) { errors.email = 'Email address is required'; } else if (!/\S+@\S+\.\S+/.test(values.email)) { errors.email = 'Email address is invalid'; } return errors; };

Great! We’ve now defined a list of form validation rules that can be plugged into any number of React Hooks, so let’s test them out.

Using Form Validation Rules inside of React Hooks

Jump over to the Form component, inside Form.js. We initialize the useForm custom React Hook at the top of the component body. Let’s pass our validate function to the useForm Hook as the second parameter:

Form.js
... import validate from './LoginFormValidationRules'; const Form = () => { const { values, handleChange, handleSubmit, } = useForm(login, validate); ...

Next, head over to our custom React Hook, at useForm.js.

Add the new validate parameter inside of the useForm function’s parentheses:

useForm.js
... const useForm = (callback, validate) => { ...

Remember, validate takes an object, values, and returns another object, errors.

For the Form component to display a list of errors, our useForm custom React Hook needs to store them in its state. Therefore, let’s declare a new useState Hook under values, called errors:

useForm.js
const [errors, setErrors] = useState({});

Finally, when a user submits the form, we first want to check that there are no issues with any of their data before submitting it.

Change the handleSubmit function to call validate instead of callback, passing in the values stored in the Hook’s state.

useForm.js
const handleSubmit = (event) => { if (event) event.preventDefault(); setErrors(validate(values)); };

Detecting Change in Errors State

We’re setting the errors state to the result of validate, but nowhere are we actually submitting the form. We need to add back the call to the callback function to our useForm Hook. But where?

Enter the useEffect Hook.

useEffect replaces the componentDidMount and componentDidUpdate lifecycle methods in React Class components.

Furthermore, by passing an array with a value inside as the second parameter to useEffect, we can tell that specific useEffect declaration to run whenever that value changes.

Let’s add a useEffect Hook that listens to any changes to errors, checks the length of the object, and calls the callback function if the errors object is empty:

useForm.js
useEffect(() => { if (Object.keys(errors).length === 0) { callback(); } }, [errors]);

The useEffect above is essentially saying, as a side effect of the value of errors changing, check if the errors object contains any keys (if it’s empty) and if so, call the callback function.

It took me a while to wrap my head around the naming of the useEffect Hook, but if you think about it like: “as a result (side effect) of [value] changing, do this”, it makes much more sense.

Preventing the Form from Submitting on Render

Before we move on to the final section, hooking up the form HTML to the errors, there’s a problem with the login function inside our Form component. It’s being called when the page loads.

This is because our useEffect Hook above is actually being run once when the component renders because the value of errors is initialized to an empty object.

Let’s fix this by adding one more state variable inside of our custom React Hook, called isSubmitting. Set the initial state to false.

useForm.js
... const [values, setValues] = useState({}); const [errors, setErrors] = useState({}); const [isSubmitting, setIsSubmitting] = useState(false); ...

Then setIsSubmitting to true inside handleSubmit.

useForm.js
const handleSubmit = (event) => { if (event) event.preventDefault(); setIsSubmitting(true); setErrors(validate(values)); };

Check that isSubmitting is true inside of the useEffect Hook:

useForm.js
useEffect(() => { if (Object.keys(errors).length === 0 && isSubmitting) { callback(); } }, [errors]);

Finally, return the errors object at the bottom of the Hook:

useForm.js
return { handleChange, handleSubmit, values, errors, }

Your finished useForm Hook should now look like this:

useForm.js
import { useState, useEffect } from 'react'; const useForm = (callback, validate) => { const [values, setValues] = useState({}); const [errors, setErrors] = useState({}); const [isSubmitting, setIsSubmitting] = useState(false); useEffect(() => { if (Object.keys(errors).length === 0 && isSubmitting) { callback(); } }, [errors]); const handleSubmit = (event) => { if (event) event.preventDefault(); setErrors(validate(values)); setIsSubmitting(true); }; const handleChange = (event) => { event.persist(); setValues(values => ({ ...values, [event.target.name]: event.target.value })); }; return { handleChange, handleSubmit, values, errors, } }; export default useForm;

Displaying Errors in the Form Component

Now our custom React Hook is saving a list of errors, let’s display them for our users to see. This is the final step to adding some proper form validation inside of any custom React Hooks.

First, make sure to add errors to the list of variables and functions we’re getting from useForm:

Form.js
const { ... errors, ... } = useForm(login, validate);

Bulma (the CSS framework we’re using) has some excellent form input classes that highlight inputs red.

Let’s make use of that class by checking if the errors object has a key that matches the input name, and if so, adds the is-danger class to the input’s className:

Form.js
<div className="control"> <input className={`input ${errors.email && 'is-danger'}`} type="email" name="email" onChange={handleChange} value={values.email || ''} required /> </div>

Finally, display the actual error message by adding an inline conditional below the input element to check again if the errors object has a key matching this input, and if so, displays the error message in red:

Form.js
<div className="control"> <input className={`input ${errors.email && 'is-danger'}`} type="email" name="email" onChange={handleChange} value={values.email || ''} required /> {errors.email && ( <p className="help is-danger">{errors.email}</p> )} </div>

Save everything, jump on over to your app running in your browser (npm start in the project if you didn’t do so already) and take your new form for a test run!

A login form validation powered by a custom react hook

Adding Additional Validation Rules

๐Ÿค” “Where’s the password validation?”, you might be thinking. 

I’m going to leave that part for you to add. Homework as it were. If you want the solution, you can check out the entire code base for this tutorial.

Wrapping Up

So there you have it, form validation and initialization using custom React Hooks. As always, if you enjoyed the tutorial, please leave a message in the discussion below.

If you have any issues or questions, leave a comment below or hit me up on Twitter. It’d be my pleasure to help. Peace!

Additional Reading

๐Ÿ† Most Read

๐Ÿ“ฌ The Monthly Upmostly Newsletter

One email a month, packed with the latest React tutorials, delivered straight to your inbox.
Zero spam, just great content. Unsubscribe at any time.
James King headshot
๐Ÿ‘‹ Hey, I'm James King
My tutorials help 60,000+ developers learn React and JavaScript every month. If you'd like to receive a friendly email once in a while of all new React tutorials, just pop your email above! I appreciate the support!

๐Ÿ’ฌ Leave a comment

Your email address will not be published. Required fields are marked *

We will never share your email with anyone else.

Comments

Juliรกn ร says:

Thanks for this great post!

Jack says:

Hey James, thanks for this article!

I’m wondering how you’d tackle this use case – I have a form that I use for both creating and editing. When it first loads it’s in “create mode”, but if I select an item elsewhere on the page, I want to reinitialise the fields with that item’s values (“edit mode”). Assume that the new initial values are being passed in as props to the form component.

How would you go about reinitialising in this way using hooks?

Roei says:

Thank you very much sir, you really helped me!

Haukur says:

Thank you for this post, I love this approach ๐Ÿ˜‰
But what I don’t like though is initialising the values from the value attribute by using the syntax you suggested: value={values.email || ”}
Just want to have value={values.email}

So I wanted to have some initialValues there. so I forked it and changed it so I now pass initialValues as the first parameter into useForm. I also made the validate function optional.

See the fork here: https://github.com/haukurmar/custom-react-hooks-form-validation

What do you think?

bill says:

nice article ๐Ÿ™‚ check out my hook for form validation.
https://github.com/bluebill1049/react-hook-form

Anonymous says:

I made useForm initialize using values from a prop. One thing to note is to have a useEffect that updates the state of values each time the prop changes else it uses the old value and introduces bug.
useEffect(() => {
setValues(initialValues)
},
[initialValues]
)

Zm says:

How is this different from useReducer (https://reactjs.org/docs/hooks-reference.html#usereducer)?
The only thing I found is validation should be induced separately if we are using useReducer hook.
@JamesKing, any suggestions?

Oleg Musin says:

Good example! I have one question. By this structure you are assuming that your form invalid initially, and that’s ok. From other side your are triggering validation on submit only, right? Would it better to use instead or additionally input value change or onBlur event for validation check, so that user could immediately see that he had entered correct value and fixed invalid input issue?

James King says:

That’s a good question, and I’d say that it’s more of a UX preference. It does provide quicker feedback to the user, as you say, so why not?

Wiktor Pล‚ocki says:

There’s a slight bug within useForm.js – you’re not setting the isFormSubmitting state to false after calling the callback function.
This leads to callback spillover in situations where your callback function is some more advanced logic to be performed, such as a login call to a GraphQL API. Had I not fixed the state setting, my form would keep calling the API with every single change in my inputs(I also use blur events)

Amet Alvirde says:

I followed a different approach to handle the controlled to uncontrolled issue:

I send an initialdFormfields object to useForm. As far as the form will initialize empty (as in a login) its exactly the same.

But, wether I solve the controlled to uncontrolled problem with my strategy or yours, if you want to initialize the form with previous values depending on a component props, it will fail.

Component consuming useForm:

const ComponentConsumingUseForm = ({ initialFormValuesAsProps }) => {
const initialFormFields = {
name: initialFormValuesAsProps.name,
lastname: initialFormValuesAsProps.lastname
};
const { values, errors, handleChange, handleSubmit } = useForm(
() => {
//Stuff
},
initialFormFields,
validate
);

UseForm:
const useForm = (callback, initialFormFields = {}, validate) => {
const [values, setValues] = useState(initialFormFields);

}

values.name and values,lastname will be set as undefined, then to its real value, but the UI wont catch up, rendering an empty input.

Is there a solution for that?

Thank you so much in advance.

Francis Rodrigues says:

How can I make it based on JSON data?
https://codesandbox.io/s/6nv3ykk5zk

James King says:

You can build a form dynamically from JSON two ways: using a library, or writing your own form generator. The decision depends on how comfortable you are with JavaScript and React.

Dan Page says:

Great two part article, very well explained!

Martin says:

Nice post!
I recommend use classnames or clsx libraries for dynamic classNames settings.

James King says:

Thanks, Martin! I’ve used the classnames library before and really liked it. It’s also pretty much a must when working with dynamic class names, right? Otherwise you litter the class attribute with false all the time.

Tomek says:

Thanks James!
I have extended the hook to support onChange validation. Instead of passing the validate function, I am passing a list of validators in a form of {key: fn}
That way in onChange, we can see if there is a validator for a particular field and run validation then. The onSubmit simply runs all validators.

const useForm = (callback, validators) => {

const handleChange = event => {
const {id, value} = event.target

event.persist()

const validator = validators[id]
if (validator) {
setErrors(errors => ({…errors, [id]: validator(value)}))
}

setValues(values => ({…values, [id]: value}))
}

const validate = values => {
let errors = {}
Object.keys(validators).forEach(id => {
const validator = validators[id]
if (validator) {
const error = validator(values[id])
if (error) {
errors[id] = error
}
}
})

return errors
}

syed says:

useEffect(()=>{

if (isSubmitting && Object.keys(err).length ===0) {
callback();
}
},[err] );

in the above code, callback(); function calling at first time loading, thought isSubmitting is false at first

Sandeep Ravitej says:

Thanks, James for the effort you put to bring the awesome content each time ..
Many are remote learners like me …God bless you

Matt Moore says:

This is an elegantly simple solution and a great article as well. Thank you for sharing this with us!

Lauchlan says:

Great article, however I have found one issue.

If you use the react-hooks plugin for eslint (recommended by the react team as best practice), you’ll find that your useEffect hook requires the isSubmitting, and callback to be included in your dependency array.

This causes the form to be submitted twice for me (which makes sense, once when isSubmitting is changed to true, and again when setErrors is set coming back as an empty array after validation). Without further changes to your approach, this can only be remedied by disabling eslint for the useEffect, something I don’t want to do.

Any thoughts on how you’d go about fixing this?

James King says:

Hi Lauchlan,

You need to include a conditional inside of the useEffect Hook to check isSubmitting. The reason for this is because the state variables are initialized when the component mounts, and therefore the useEffect will trigger.

Johnny says:

Thank you, sir. I have learned more from your post, but I got this warming: “React Hook useEffect has missing dependencies: ‘callback’ and ‘isSubmitting’. Either include them or remove the dependency array. If ‘callback’ changes too often, find the parent component that defines it and wrap that definition in useCallback”.

I saw your comment about adding a conditional inside of useEffect(), but I still got the same error.

James King says:

I’ve found the linting warnings for useEffect to be slightly sensitive.

Flexroad says:

Hi there, many thanks for nice article!

I have added two more lines to your useForm as I wanted to setErrors when the input is changing ๐Ÿ˜‰

“`
import { useState, useEffect } from ‘react’;

export const useForm = (callback, validate) => {

const [values, setValues] = useState({});
const [errors, setErrors] = useState({});
const [isSent, setIsSent] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);

useEffect(() => {
if (Object.keys(errors).length === 0 && isSubmitting && isSent) {
callback();
}
}, [errors]);

const handleSubmit = (event) => {
if (event) event.preventDefault();
setErrors(validate(values));
setIsSubmitting(true);
setIsSent(true);
};

const handleChange = (event) => {
event.persist && event.persist();
setIsSubmitting(false);
const newValues = { …values, [event.target.name]: event.target.value };
isSent && setErrors(validate(newValues));
setValues(values => (newValues));
};

return {
handleChange,
handleSubmit,
values,
errors,
}
};
“`