Form validation with React Hooks WITHOUT a library: The Complete Guide

In the early days of the internet, HTML forms were the first way of interacting with websites. The internet has come a long way since, but forms are still an essential component of any web application.

In React, you can validate forms in many different ways. There are some libraries out there that intend to make this task easier for you.

However, doing it yourself has a few advantages: You have control over the API layout, and since you probably don't need all the features that these libraries come with, you also save some bandwidth. More importantly, though, you get a deeper understanding of the mechanics behind some of these libraries.

In this tutorial, I want to show you how you can create a custom hook that you can use to validate all of your forms. The code for this doesn't need to be huge; in fact, the TypeScript version counts less than 100 lines.

The way I designed this hook fits my personal needs, but your mileage may vary. If you use a different implementation or have suggestions, please leave a comment, as I'm always looking for improvements.

Please note that in this tutorial, I focus on client-side validations. However, you can easily adapt the hook to validate your data on the backend as well.

What we're building

In this tutorial, we're going to build a registration form with validation on the frontend.

We're going to create a hook that will be able to do the following:

  • Validate user input
  • Update form data
  • Handle form submission

In the second section of this article, I'll walk you through the process of adding TypeScript definitions to this hook.

The component will be reusable for any form in your application.

Demo

Here's how the component looks like. Click on "submit" or change the values to try it out.

I set up a few validation rules, like that the user needs to be at least 18 and the password needs to be at least six characters long.

Registration

You can find the entire code for this on GitHub.

Using the resulting hook will look something like the following.

const {
  handleSubmit, // handles form submission
  handleChange, // handles input changes
  data, // access to the form data
  errors, // includes the errors to show
} = useForm({ // the hook we are going to create
  validations: { // all our validation rules go here
    name: {
      pattern: {
        value: '^[A-Za-z]*$',
        message:
          "You're not allowed ...",
      },
    },
  },
  onSubmit: () => alert('User submitted!'),
  initialValues: { // used to initialize the data
    name: 'John',
  },
});

// ...
return (
  <form onSubmit={handleSubmit}>
    <input value={data.name || ''} onChange={handleChange('name')} required />
    {errors.name && <p className="error">{errors.name}</p>}
    {/** ... */}
  </form>
);

You don't need to understand every detail of what is going on here. I'll explain everything in the next section.

Tutorial

In this section, we'll finally build the hook I've been talking about before.

Let's dive right into it.

Creating the hook and updating form data

First, we need to create a function that accommodates our form logic. I've put mine into its own file.

useForm.js
export const useForm = (options) => {
  // all logic goes here
};

We use React's useState hook to manage the state of our form.

const [data, setData] = useState(options?.initialValues || {});

This and the following code snippets go into the useForm hook.

The handleChange function is responsible for managing the change events of our input elements.

We need a way to tell the function which attribute of the form it manages.

For this reason, we call the function with the name of the attribute (key).

Optionally, we can pass down a function for sanitizing the value (for transforming strings into numbers, for example).

handleChange then returns a function that uses the React event to update the form state.

const handleChange = (
  key,
  sanitizeFn,
) => (e) => {
  const value = sanitizeFn ? sanitizeFn(e.target.value) : e.target.value;
  setData({
    ...data,
    [key]: value,
  });
};

We also need a function that handles the form submission, which calls the onSubmit function that the hook has been initialized with.

const handleSubmit = async (e) => {
  e.preventDefault();
  if (options?.onSubmit) {
    options.onSubmit();
  }
};

We need to call e.preventDefault() because it prevents the page from reloading on submission, which is the default HTML form behavior.

After putting these elements together, our hook looks like the following.

At the end of it, we return each function so we can use it in our components.

export const useForm = (options) => {
  const [data, setData] = useState(options?.initialValues || {});

  const handleChange = (
    key,
    sanitizeFn,
  ) => (e) => {
    const value = sanitizeFn ? sanitizeFn(e.target.value) : e.target.value;
    setData({
      ...data,
      [key]: value,
    });
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    if (options?.onSubmit) {
      options.onSubmit();
    }
  };

  return {
    data,
    handleChange,
    handleSubmit,
  };
};

We could already use this for our forms. However, it doesn't include any of the validation logic we've seen in the demo at the beginning.

Adding validations

There are many ways to specify the validation rules for our form attributes. The three ways that would allow for the greatest flexibility are the following:

  • Required: Marks an attribute as required and throws a validation error if it is missing.
  • Pattern: This allows you to specify a regular expression that the attribute must match to be considered valid.
  • Custom: A custom function that accepts the value as a parameter and which returns a boolean. If it returns true, the field is valid.

I've decided to store them in an object where the key refers to the attribute's name and the value contains the validation.

This is easier understood with an example. The way we need to specify the validations then looks like this.

const { handleSubmit, handleChange, data, errors } = useForm({
  validations: {
    name: {
      pattern: {
        value: '^[A-Za-z]*$',
        message: "You're not allowed to...",
      },
    },
    age: {
      custom: {
        isValid: (value) => parseInt(value, 10) > 17,
        message: 'You have to be at least 18 years old.',
      },
    },
    password: {
      required: {
        value: true,
        message: 'This field is required',
      },
      custom: {
        isValid: (value) => value.length > 6,
        message: 'The password needs to be at...',
      },
    },
  },
});

We could have chosen another data structure to store our validations, such as an array, but I think this way is the easiest for us to process in the hook.

Returning errors

To accommodate the errors, we first need to create a new state variable with useState and return it to access it from outside the hook.

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

// ...

return {
  data,
  handleChange,
  handleSubmit,
  errors,
};

In our handleSubmit function, we now need to add the logic for validating our keys.

I'm not too fond of forms that validate the input while typing because the error messages suggest that you've done something wrong, even though you aren't finished yet with filling out the form.

Just as we did with the validations object, the errors object keys also reference the attribute's name, and the value will be the error message to display in the UI.

As an example, an errors object with validation errors may look like this:

{
  name: 'This field isn\'t allowed to contain special characters',
}

Validating the data

As the first step for validating the form data, we need to check if the hook has been initialized with a validations object.

// Check if there are validations specified
if (validations) {
  // ...
}

Next, we iterate over the values of the validations object.

For each key of the object, we check if there are any validations available, and if they do exist, we run the validation rule against the current value.

let valid = true;
const newErrors = {};
for (const key in validations) {
  // value of the field we're validating
  const value = data[key];
  // the matching validation rule for this key
  const validation = validations[key];

  // REQUIRED  if (validation?.required?.value && !value) {    valid = false;    newErrors[key] = validation?.required?.message;  }  // PATTERN  const pattern = validation?.pattern;  if (pattern?.value && !RegExp(pattern.value).test(value)) {    valid = false;    newErrors[key] = pattern.message;  }  // CUSTOM  const custom = validation?.custom;  if (custom?.isValid && !custom.isValid(value)) {    valid = false;    newErrors[key] = custom.message;  }}

The last thing we need to do is updating the errors object with the data from newErrors if any of the before-mentioned validation rules failed.

if (!valid) {
  setErrors(newErrors);
  return;
}

I also added an empty return statement because this prevents the handleSubmit function from submitting the form.

Our component then looks like the following after putting everything together:

export const useForm = (options) => {
  const [data, setData] = useState((options?.initialValues || {}));
  const [errors, setErrors] = useState({});

  const handleChange = (key, sanitizeFn) => (
    e,
  ) => {
    const value = sanitizeFn ? sanitizeFn(e.target.value) : e.target.value;
    setData({
      ...data,
      [key]: value,
    });
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    const validations = options?.validations;
    if (validations) {
      let valid = true;
      const newErrors = {};
      for (const key in validations) {
        const value = data[key];
        const validation = validations[key];
        if (validation?.required?.value && !value) {
          valid = false;
          newErrors[key] = validation?.required?.message;
        }

        const pattern = validation?.pattern;
        if (pattern?.value && !RegExp(pattern.value).test(value)) {
          valid = false;
          newErrors[key] = pattern.message;
        }

        const custom = validation?.custom;
        if (custom?.isValid && !custom.isValid(value)) {
          valid = false;
          newErrors[key] = custom.message;
        }
      }

      if (!valid) {
        setErrors(newErrors);
        return;
      }
    }

    setErrors({});

    if (options?.onSubmit) {
      options.onSubmit();
    }
  };

  return {
    data,
    handleChange,
    handleSubmit,
    errors,
  };
};

If you've followed along with the tutorial, you can now specify validation rules and access the errors object to show validation errors.

const { errors, data } = useForm({
  // ...
});

// ...

<form handleSubmit={handleSubmit}>
  {errors.name && <p className="error">{errors.name}</p>}
</form>

In the next section, I'll explain how we can create type definitions in TypeScript for this hook.

If that doesn't interest you, click here to skip to the conclusion.

Adding TypeScript

If you're a TypeScript user, you were probably suspicious about such a generic function that can be used for any type of data.

The more generic a function is, the harder it gets to get the typings right without dropping the any hammer to "make it work".

Let's have a look at how we can get type-safety for this hook.

Form data type

The most crucial definition we're going to need is for the form data, which should be some kind of object.

We will pass down the interface of the form data down to the hook via a generic.

useForm<User>();

In the example above, User is the interface that we pass to the useForm hook. You probably know this syntax from the useState hook already.

If you don't know what generics are, you can think of them as function arguments, but for types.

We can specify a generic and reuse that throughout the interfaces that we define in our hook.

The advantages, in this case, are that the validations object, and the returned data and errors objects are strongly typed.

Autocompletion after adding the generic type

With the power of generics, the returned data of our hook is strongly typed and offers TypeScript features like autocompletion.

Adding a generic to the hook looks like the following.

We can add a generic type to any function by adding ankle brackets in front of the function's parentheses.

export const useForm = <T extends Record<keyof T, any> = {}>(options?: {  // We will soon see how to create this interface
  validations?: Validations<T>;
  // Allows a subset of T
  initialValues?: Partial<T>;
  onSubmit?: () => void;
}) => {

I called the parameter T, but you can give it any name you want. T seems to be the common name for generics in TypeScript.

The generic T extends Record, which means that we expect an object.

In case the user doesn't pass down any interface, the generic falls back to an empty object (= {}).

To make the returned data object strongly typed, we need to add the previously defined generic to the useState hook.

const [data, setData] = useState<T>((options?.initialValues || {}) as T);

We need to cast the initial value to T because {} is only a subset of T.

Validation type

As you may have seen in the previous section already, I created a separate Validations type that accepts a generic.

Our validations are stored in an object, which keys are the same as the form data's keys.

The Record utility type allows us to specify precisely that. It accpets two generics: The first one specifies the type of the keys, while the second one defines the types of the values.

Since the user shouldn't be required to fill out all keys, we need to wrap this inside a Partial type, making every property optional.

type Validations<T extends {}> = Partial<Record<keyof T, Validation>>;

Last, creating the Validation interface, which is used by the record, is pretty straightforward.

interface Validation {
  required?: {
    value: boolean;
    message: string;
  };
  pattern?: {
    value: string;
    message: string;
  };
  custom?: {
    isValid: (value: string) => boolean;
    message: string;
  };
}

Error type

Our errors are also a record whose keys should match the keys of the form data. The values of the object are all strings.

type ErrorRecord<T> = Partial<Record<keyof T, string>>;

We need to wrap the type in the Partial helper function to allow empty objects ({}).

We use this type when creating the state for our errors with useState.

const [errors, setErrors] = useState<ErrorRecord<T>>({});

Typing functions

We now need to add type definitions to the functions we defined in the hook: handleChange and handleSubmit.

Let's first have a look at what handleChange looks like with type definitions.

// Needs to extend unknown so we can add a generic to an arrow function
const handleChange = <S extends unknown>(
  key: keyof T,
  sanitizeFn?: (value: string) => S
) => (e: ChangeEvent<HTMLInputElement & HTMLSelectElement>) => {
  const value = sanitizeFn ? sanitizeFn(e.target.value) : e.target.value;
  setData({
    ...data,
    [key]: value,
  });
};

The types for the first argument (key: keyof T) is something we've already seen before, stating that the value needs to match one of the form data keys.

The second parameter, sanitizeFn, is optional and accepts a function that takes a value and sanitizes it.

The value is set to string because that's what change events always return.

However, the return value can't be known before, so I decided to add another generic for this function (S).

The function returns another function, which is the actual event handler. This accepts the ChangeEvents for input and select elements.

(e: ChangeEvent<HTMLInputElement & HTMLSelectElement>) => {
  // ...
}

The other function we need to make typesafe is handleSubmit.

We will only use it for form elements, so we pass the HTMLFormElement type to FormEvent.

const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
  // ...
};

Conclusion

Congratulations! If you followed along with the tutorial, you now have a hook that you can use to manage any form that comes your way 🎉.

If this article helped you, and you want to get notified about more articles like this one, consider subscribing to my email list, where I share new posts and some background information on the content.

In case you have any open questions or feedback, feel free to leave a comment or send me an email.

Related links and resources:

PatreonKoFi