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
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:
<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
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 error message 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:
<input
...
value={values.email || ''}
...
/>
I sometimes think it’s helpful to explain
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.
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.
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.
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.
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. If you want to learn more about hooks in React check out this guide.
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:
...
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:
...
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:
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.
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
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
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:
useEffect(() => {
if (Object.keys(errors).length === 0) {
callback();
}
}, [errors]);
The
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
...
const [values, setValues] = useState({});
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
...
Then setIsSubmitting to true inside handleSubmit.
const handleSubmit = (event) => {
if (event) event.preventDefault();
setIsSubmitting(true);
setErrors(validate(values));
};
Check that
useEffect(() => {
if (Object.keys(errors).length === 0 && isSubmitting) {
callback();
}
}, [errors]);
Finally, return the errors object at the bottom of the Hook:
return {
handleChange,
handleSubmit,
values,
errors,
}
Your finished useForm Hook should now look like this:
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:
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:
<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:
<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!
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
- Using Custom React Hooks to Simplify Forms
- Simple Introduction to React Hooks
- How to Use the useContext Hook in React
Comments
Thanks for this great post!
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?
Thank you very much sir, you really helped me!
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?
nice article 🙂 check out my hook for form validation.
https://github.com/bluebill1049/react-hook-form
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]
)
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?
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?
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?
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)
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.
How can I make it based on JSON data?
https://codesandbox.io/s/6nv3ykk5zk
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.
Great two part article, very well explained!
Nice post!
I recommend use classnames or clsx libraries for dynamic classNames settings.
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.
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
}
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
Thanks, James for the effort you put to bring the awesome content each time ..
Many are remote learners like me …God bless you
This is an elegantly simple solution and a great article as well. Thank you for sharing this with us!
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?
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.
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.
I’ve found the linting warnings for useEffect to be slightly sensitive.
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,
}
};
“`
Wonderful post! Explained succinctly and clearly,
Thank you so much, very useful
Hello James, nice tutorial, but i have a little problem. The bulma library is affecting my existing css files on my React hooks project. I’ve tried several ways to separate them.. Is there a way not to let them affect each other? Thank you
In the handleSubmit function after the callback i would reset isSubmitting state to false. But anyway, really great thing!
Thanks for this Great tutorail.
Excellent work.
It’s short, clean and helpful.