Building forms quickly with React, React Hook Form, TailwindCSS and Yup

Building forms quickly with React, React Hook Form, TailwindCSS and Yup

I often get asked how I'm able to build up solid, functional UI in React so fast, especially with complex forms. If you're building any kind of substantive application, you're going to be working with a lot of forms.

These days, one of my favorite combinations for robust, fast forms is:

  • React Hook Form - My favorite form library. It defaults to uncontrolled components, which makes it very performant with respect to rendering.

  • TailwindCSS / TailwindUI - I can do the HTML / CSS... I just don't want to. And, the amazing team at Tailwind has already done it for me.

  • Yup - I have become a huge fan schema validation, and use it for nearly everything now. Yup makes building validation schemas super easy.

So, without further ado, let's get started! 🚀

Initialize Tailwind

Before you get started, you'll need to initialize Tailwind in your app. Follow this guide to complete this step.

Install Libraries

Next, install your dependencies:

yarn add react-hook-form @hookform/resolvers yup

Now, we're ready to start building our form.

Grab the Form Markup from TailwindUI

Head over to the TailwindUI forms section and find a Sign In form that you like. I picked this one:

Simple Tailwind Sign In Form

Create a file in your app like /components/SignInForm.js, and paste in the following code.

Super fast and easy, right? Now lets start wiring things up.

Add React Hook Form

Now that we have our base markup for our form, let's set up React Hook Form, with a basic onSubmit() function.

/components/SignInForm.js
import { useForm } from 'react-hook-form'

export default function SignInForm() {
  const { handleSubmit } = useForm();
  
  async function onSubmit(values) {
    alert(JSON.stringify(values, null, 2))
  }

  return (
    <>
      <div className="flex min-h-full flex-1 flex-col justify-center px-6 py-12 lg:px-8">
        {/* ... */}

        <div className="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
          <form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
            {/* ... rest of form */}
          </form>

          {/* ... */}
        </div>
      </div>
    </>
  )
}

Now our form is wired up with an onSubmit, but but it doesn't do much. Right now, it just alerts the form values, but since we haven't wired up any values yet, it simply logs and empty object. Go ahead and try it out:

To start sending data to our onSubmit, we need to use the React Hook Form register method.

/components/SignInForm.js
import { useForm } from 'react-hook-form'

export default function SignInForm() {
  const { handleSubmit, register } = useForm();

  async function onSubmit(values) {
    alert(JSON.stringify(values));
  } 
  return (
    <>
      <div className="flex min-h-full flex-1 flex-col justify-center px-6 py-12 lg:px-8">
        {/* ... */}

        <div className="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
          <form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
            <div>
              <label htmlFor="email" className="block text-sm font-medium leading-6 text-zinc-900">
                Email address
              </label>
              <div className="mt-2">
                <input
                  {...register('email')}
                  autoComplete="email"
                  className="block w-full rounded-md border-0 py-1.5 text-zinc-900 shadow-sm ring-1 ring-inset ring-zinc-300 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
                />
              </div>
            </div>

            <div>
              <div className="flex items-center justify-between">
                <label htmlFor="password" className="block text-sm font-medium leading-6 text-zinc-900">
                  Password
                </label>
                <div className="text-sm">
                  <a href="#" className="font-semibold text-indigo-600 hover:text-indigo-500">
                    Forgot password?
                  </a>
                </div>
              </div>
              <div className="mt-2">
                <input
                  {...register('password')}
                  type="password"
                  autoComplete="current-password"
                  className="block w-full rounded-md border-0 py-1.5 text-zinc-900 shadow-sm ring-1 ring-inset ring-zinc-300 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
                />
              </div>
            </div>

            {/* ... */}
          </form>

          {/* ... */}
        </div>
      </div>
    </>
  )
}

You can see below that the register function binds our inputs to our React Hook Form. Under the hood, RHF is using refs to limit the number of re-renders, keeping our form performant.

Add Schema Validation

Now, let's validate our form using yup for schema validation.

/components/SignInForm.js
import { useForm } from 'react-hook-form'
import * as yup from 'yup'
import { yupResolver } from '@hookform/resolvers/yup'

const schema = yup.object().shape({
  email: yup
    .string()
    .email('Please enter a valid email.')
    .required('Please enter your email address.'),
  password: yup.string().required('Please enter your password').min(8, 'Password must be at least 8 characters.'),
});

export default function SignInForm() {
  const { handleSubmit, register } = useForm({
    resolver: yupResolver(schema)
  });

  async function onSubmit(values) {
    alert(JSON.stringify(values));
  }

  {/* ... */}

}

Go ahead, try it out. Notice that the form no longer alerts our values. Weird, huh?

What's going on here is that RHF is preventing our form from being submitted, because our schema doesn't pass validation.

However, we haven't built any UI to show these error messages in our form yet. So let's do that now.

Create a new file at /components/Error.js with the following content:

/components/Error.js
export function Error({ name, errors }) {
  if (!errors || !errors[name] || !errors[name].message) {
    return null;
  }

  return (
    <div className="mt-2 text-xs text-red-500 font-light">
      {errors[name].message}
    </div>
  );
}

export default Error;

Then, in your form, import the new <Error /> component, and connect it to the errors object in your form state.

/components/SignInForm.js
import { useForm } from 'react-hook-form'
import * as yup from 'yup'
import { yupResolver } from '@hookform/resolvers/yup'

import Error from './Error'

const schema = yup.object().shape({
  email: yup
    .string()
    .email('Please enter a valid email.')
    .required('Please enter your email address.'),
  password: yup.string().required('Please enter your password').min(8, 'Password must be at least 8 characters.'),
});

export default function SignInForm() {
  const { handleSubmit, register, formState: { errors } } = useForm({
    resolver: yupResolver(schema)
  });

  async function onSubmit(values) {
    alert(JSON.stringify(values));
  } 
  return (
    <>
      <div className="flex min-h-full flex-1 flex-col justify-center px-6 py-12 lg:px-8">
        {/* ... */}

        <div className="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
          <form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
            <div>
              <label htmlFor="email" className="block text-sm font-medium leading-6 text-zinc-900">
                Email address
              </label>
              <div className="mt-2">
                <input
                  {...register('email')}
                  autoComplete="email"
                  className="block w-full rounded-md border-0 py-1.5 text-zinc-900 shadow-sm ring-1 ring-inset ring-zinc-300 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
                />
              </div>
              <Error name="email" errors={errors} />
            </div>

            <div>
              <div className="flex items-center justify-between">
                <label htmlFor="password" className="block text-sm font-medium leading-6 text-zinc-900">
                  Password
                </label>
                <div className="text-sm">
                  <a href="#" className="font-semibold text-indigo-600 hover:text-indigo-500">
                    Forgot password?
                  </a>
                </div>
              </div>
              <div className="mt-2">
                <input
                  {...register('password')}
                  type="password"
                  autoComplete="current-password"
                  className="block w-full rounded-md border-0 py-1.5 text-zinc-900 shadow-sm ring-1 ring-inset ring-zinc-300 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
                />
              </div>
              <Error name="password" errors={errors} />
            </div>

            {/* ... */}
          </form>

          {/* ... */}
        </div>
      </div>
    </>
  )
}

Let's see how that works. Try submitting an empty form, then start filling it out with valid values.

Pay attention to what happens with the UI.

Et voila! An awesome, mobile-responsive, excellently styled Sign In form, complete with schema based validation. ✨🦄

This is the pattern I use for pretty much every form I write. It's simple, clean, and has very little magic.

Not to mention, it's lighting fast to build. ⚡️

I hope this post was helpful, and happy coding! 😎

If you enjoyed this article, please consider following me on Twitter

Subscribe to the Newsletter

Subscribe for exclusive tips, strategies, and resources to launch, grow, & build your SaaS team.

Share this article on: