Skip to content
GitHub Twitter

Seamless User Experience: Next.js Forms with useFormState and Tailwind CSS

These days it is crucial for websites to provide a solid user experience (UX). With so many tools readily available, it has never been easier to achieve.

Today we will dive into the useFormState React hook, integrate it into forms in Next.js, and use Tailwind CSS to provide informative, stylised feedback.

You can see this in action on my GitHub repo!

useFormState

In short, useFormState is an experimental (as of time of writing) React hook that allows you to update the form state based on the result of a form action.

I first discovered this in the Next.js docs for server-side validation and error handling. With my go to approach usually being to pass an event handler into the onSubmit attribute, I wanted to see how using this hook differed. Going forward I will use my template as an example for each of the steps.

To get started, you need to call useFormState at the top level of your component.

"use client";

import { useFormState } from "react-dom";
import { registerUser, FormState } from "./action";

const initialState: FormState | null = null;

function MyForm() {
  const [formState, formAction] = useFormState(registerUser, initialState);

  return <form action={formAction}>...</form>;
}

Here you can see it returns two items we can use formAction and formState.

Form Action

The action is simply passed into the action attribute. While this has no practical benefit to the end user, there is something about it using a standard HTML attribute that I find to be so much cleaner. Coupled with an import from an action file, you can ensure all your form components that you create are always using this folder structure:

/components
    /my-form
        action.ts
        index.tsx

Chef's kiss 🤌.

I've immediately gone on a tangent about code structure which serves no purpose with UX, but I felt it just needed to be said - that will be the last time I do, sorry!

As for the creating the action, you need a function that accepts two arguments: the current state of the form, and the form data.

export async function registerUser(
  prevFormState: FormState | null,
  formData: FormData
): Promise<FormState> {
    ...
}

With this setup, you can now submit your form and get the form data in your action, nice and easy! Now we just need to start making use of the form state.

Form State

So how do we use the form state then? Let's go back to formState value mentioned earlier. You may have noticed the initialState variable that I had defined.

const initialState: FormState | null = null;

The FormState type is a custom type I defined in the action file, which looks like:

{
  message?: string;
  errors?: any;
}

So, either our form state is null, or if it is set then we have a message or some errors.

Note - I usually try to avoid the any type, but even when I set the types I was still getting a type error for whatever reason, not sure why this was happening!

We can display the message and/or errors from our form by doing the following:

<input type="text" name="username" />;
{
  formState?.errors?.username && <p>{formState.errors.username}</p>;
}

{
  formState?.message && <p>{formState.message}</p>;
}

We can conditionally show an element if there are any errors set for that input, as well as show a message should we need to!

With the form action created, as well as using the form state, we are ready to show some form validation feedback to the user.

Validation

This post isn't too concerned with how we validate the form, more so how we show the information to the user, so I won't go into too much detail here. All that is important is if validation fails, we return the error in the errors property in the form state for each of the fields. In this example, I will validate a username, email, and password using Zod.

import { z } from "zod";

const schema = z.object({
  username: z.string().max(30, "Username must be under 30 characters"),
  email: z.string().email().max(50, "Email must be under 30 characters"),
  password: z.string().min(8),
  confirmPassword: z.string().min(8),
});

export async function registerUser(
  prevFormState: FormState | null,
  formData: FormData
): Promise<FormState> {
    const validatedFields = schema.safeParse({
        username: formData.get("username"),
        email: formData.get("email"),
        password: formData.get("password"),
        confirmPassword: formData.get("confirm-password"),
    });

    if (!validatedFields.success) {
        return {
            errors: validatedFields.error.flatten().fieldErrors,
        };
    }

    if (
        validatedFields.data.password !== validatedFields.data.confirmPassword
    ) {
        return {
            errors: { password: "Passwords do not match" },
        };
    }

    // Manipulate data
    ...
}

Pretty simple validation, but as you can see, all that we need to do is return an object with the errors according to the schema that we set.

Now when we submit our form and the data is invalid, our error messages will appear under that input! This is great for improved UX, but now we need to style it up a bit.

Tailwind CSS

Tailwind CSS has some classes that are perfect for displaying error messages for specific inputs. We can make use of the classes that change the colour of the input border and text to get the users attention when one of the inputs is invalid.

<input
  name="email"
  type="email"
  className={
    `ring-1 focus:ring-2 focus:outline-none placeholder:text-gray-400 ` +
    (formState?.errors?.email
      ? "ring-pink-600 text-pink-600"
      : "ring-gray-300 text-gray-900")
  }
/>;

{
  formState?.errors?.email && (
    <p className="text-pink-600 text-xs">{formState.errors.email}</p>
  );
}

Here we conditionally add the ring and text colour classes on the input element. You can use whatever styling you like, but after adding some more styling in my demo we get:

Invalid Input

Against the other gray inputs, it really stands out and draws the user's attention as to which field is invalid. Perfect!

Conclusion

This tutorial covered a simple approach for handling form submissions by using the form state object. We can easily make use of Tailwind classes to highlight input elements that are invalid, which are set from the validation we carry out in our form action.

There is a lot more we can do with it too to further enhance the UX. This is a basic example, but in my next blog post I will go into how we can use feedback in real-time to provide instant feedback without having to submit the form.

On another note, if you have read this far - thank you! I know I'm not the best at writing posts like this, but I want to keep talking about new tech that I enjoy, so I'm sure I will improve as I go along 😁