If you've worked with more forms within Next.js (or React in general), chances are you've used react-hook-form, a "performant, flexible and extensible forms with easy-to-use validation" (as per their website).

I've used react-hook-form for a long time now and with the recent announcement of Server Actions becoming stable in Next.js 14 I've gotten curious about how to use these tools together. After some (aka a lot more than I'm willing to admit) time I figured things out and wanted to share this knowledge with you - and after the guide we'll quickly talk about if you should even go for this.

tl;dr

Prerequisites

For this guide, we assume you've got a Next.js 14 installation. Additionally, we need react-hook-form and, as a bonus, we're gonna use Zod for advanced input validation:

npm install react-hook-form zod @hookform/resolvers

It's important to note that server actions are still pretty new and some libraries, like react-hook-form, will certainly improve support of them in the future. For this guide, I'm using react-hook-form v7.48.

Adding our form: server and client components

If you jump straight into your src/app/page.tsx and add your form like this:

import { useForm } from 'react-hook-form';

export default function Home() {
  const { register } = useForm();

  return (
    <main>
      ...
    </main>
  )
}

src/app/page.tsx

You're quickly gonna be hit with an error:

Error: createContext only works in Client Components. Add the "use client" directive at the top of the file to use it.

The reason is simple: react-hook-form uses Context in order to pass data down its components. Since our Home component is a server component by default using context doesn't work.

💡
Keep in mind that a use client component isn't a component that's solely rendered on the client. Its initial render still happens on the server which means that you don't instantly lose SEO benefits if you put use client at top of your file.

In order to solve this we could add the use client directive at the top of our file - but what if we're also trying to access some server-side data (like the current user or cookies) in the same component? You'll get another error, something like:

You're importing a component that needs next/headers. That only works in a Server Component but one of its parents is marked with "use client", so it's a Client Component.

While it seems like we're gonna need the powerful omni component, that's rendered everywhere at the same time, which unfortunately would break the way the internet works, we're gonna have to find a different, pretty easy solution: embedding a client component.

Form component

Since our form requires to be a client component we're gonna create a file for it at src/app/form.tsx:

"use client";

import { useForm } from 'react-hook-form';

export function Form() {
  const { register } = useForm();

  return (
    <>... form ...</>
  )
}

src/app/form.tsx

We can then simply use our Form component in our Home component (that's still a server component):

import { Form } from '@/app/form';

export default function Home() {
  return (
    <main>
      <Form/>
    </main>
  )
}

src/app/page.tsx

With that, our page should no longer error and we can continue adding functionality to our form.

Adding some inputs

We still have no real form; let's change that:

"use client";

import { useForm } from 'react-hook-form';

// Interface for our form values that drastically improves type safety for our form
export interface FormValues {
  firstName: string;
  lastName: string;
}

export function Form() {
  const { register } = useForm<FormValues>();

  return (
    <form>
      <input {...register("firstName")} placeholder="First name" />
      <input {...register("lastName")} placeholder="Last name" />
      <button type="submit">Send</button>
    </form>
  )
}

src/app/form.tsx

While we don't do anything with our form data yet, we have at least two simple text fields - first name and last name - and an interface that helps TypeScript (and us) to understand our form values. Later we're gonna send these values to our server action, process them, and use the return value of our server action to show a welcome message - all while validating data and accordingly handling errors.

Submitting our form

One major difference to the traditional approach of react-hook-form is how we submit our form. By default, react-hook-form suggests using onSubmit on our <form> tag - but for server actions we should use the action prop. But why would that make a difference?

A more detailed answer to this question comes a bit later in this guide, but in short the reason is how we're supposed to handle form status in the future. We're gonna use useFormStatus later in this guide which allows us to get form status information - e.g. to show a loading indicator.

Another key difference between action and onSubmit is how they handle data; while onSubmit uses native objects (defined via FormValues), action uses FormData. For type fans there's bad news: FormData is not generic - but there are some solutions already out there which we're not gonna use for this guide to keep it as simple as possible.

What we end up is directly calling our server action in our action prop:

<form action={getFullName}>

getFullName is going to be our server action name

Our server action

For now, our server action is going to be very simple. I like to put my server actions in dedicated files for better structure - so let's add a src/app/actions.ts file with the following content:

"use server";

export async function getFullName(data: FormData) {
  // we're gonna put a delay in here to simulate some kind of data processing like persisting data
  await new Promise((resolve) => setTimeout(resolve, 1000));

  console.log("server action", data);
  
  return {
    status: "success",
    message: `Welcome, ${data.get("firstName")} ${data.get("lastName")}!`,
  };
}

src/app/actions.ts

Thanks to Next.js that's all we need: simply import our getFullName action to our Form component (which already uses getFullName for our form action) and click the submit button which will execute our server action and you'll see the "server action" message and form data printed to your terminal.

Unfortunately, while what we've done is technically a form, it's certainly not a form we'd like to throw at our users. We aren't doing anything with our return, aren't validating anything, we're not showing loading states or errors - so let's come to the real fun of this guide.

Canary React features

As of today, in order to elevate the full power of server actions Next.js relies on some canary React features. If you can afford to use such features you need to update your React types in order for your types to be correct. Following a comment from the GitHub issue by havgry your types of React and react-dom should be updated as follows:

"@types/react": "18.2.36",
"@types/react-dom": "18.2.13",

package.json#devDependencies

For all things from now on we're gonna need these canary features.

Using the form return value with useFormState

Usually, server actions do something to data (or, in other words, mutate it) and we can simply revalidate data within our server action - this way we can see what happened in our UI. Since for our example app we don't have any persistent data that's going to get updated within our server action, we need to get the return value of our server action and use this instead.

In order to achieve this we're gonna need the first of our canary React features: useFormState from react-dom (not to be confused with useFormState of react-hook-form - it's not the best DX, I know).

The documentation on useFormState, especially when it comes to proper types, is a bit weak at the moment - but nothing we can't solve for ourselves.

useFormState takes two parameters: a function (our server action) and the initial state. The state is, as the name suggests, simply what might be returned from our server action - in our case: a status and a message, for now. It returns the current state and a form action. We're gonna use this hook within our Form component and change the action of our form tag to the form action returned by the hook:

// [...]
import { useFormState } from "react-dom";
import { getFullName, State } from "@/app/actions";

export function Form() {
  const [state, formAction] = useFormState<State, FormData>(getFullName, null);

  return (
    <form action={formAction}>
      [...]
    </form>
  )
}

src/app/form.tsx

Additionally, we need to adjust the signature of our server action and add a utility type for our State:

export type State =
  | {
      status: "success";
      message: string;
    }
  | null;

export async function getFullName(
  prevState: State | null,
  data: FormData,
): Promise<State> {

src/app/actions.tsx

In order to use our return value now we can simply add a useEffect that listens to changes of state:

useEffect(() => {
  if (!state) {
    return;
  }

  if (state.status === "success") {
    alert(state.message);
  }
}, [state]);

within our Form component

Now every time we execute our server action we're gonna get a window alert with the message returned from our server action.

Always showing your recent server action state is possible with:

<>{state && JSON.stringify(state)}</>

Loading indicators

You're still here? Awesome! Let's implement loading indicators.

If you're used to using libs like react-query you're likely going to know how easy it is to show loading indications for requests:

const { data, isLoading } = useMutation(...)

And isLoading will always reflect your current request loading state.

But how to do that with server actions? Well, unfortunately, it's a bit more complicated (again).

More canary features: useFormStatus

The first and most likely soon-to-be-standard approach would be to use yet another canary React feature called useFormStatus. Don't forget to update your types in order to use this hook!

useFormStatus is only available within <form>. This means we need to split up our Form component in an outer Form component and a FormContent component that's within <form>. Our FormContent component will simply be everything we'd normally put within our <form> tag and take all relevant arguments we're gonna need from our useForm hook from react-hook-form. After creating our FormContent component we can simply use useFormStatus in order to get information about the form status:

"use client";

import { useForm } from 'react-hook-form';
import { getFullName, State } from "@/app/actions";
import { useFormState, useFormStatus } from "react-dom";

// Interface for our form values that drastically improves type safety for our form
export interface FormValues {
  firstName: string;
  lastName: string;
}

// Everything within our <form> tag
export function FormContent({
  register,
  isValid,
  errors,
}: {
  register: UseFormRegister<FormValues>;
  isValid: boolean;
  errors: FieldErrors<FormValues>;
}) {
  // Pending reflects the loading state of our form
  const { pending } = useFormStatus();

  return (
    <>
      <input {...register("firstName")} placeholder="First name" />
      <input {...register("lastName")} placeholder="Last name" />
      <button type="submit" disabled={pending || !isValid}>Send</button>
      {pending && <span>Loading...</span>}
    </>
  );
}

export function Form() {
  const { register, formState: { isValid, errors } } = useForm<FormValues>();
  const [state, formAction] = useFormState<State, FormData>(getFullName, null);

  return (
    <form action={formAction}>
      <FormContent register={register} isValid={isValid} errors={errors} />
    </form>
  )
}

src/app/form.tsx

With that, our submit button gets disabled and the "Loading" text is only shown while our server action is executing.

Alternative: useTransition

A different approach to show loading indicators would be to use the useTransition hook of React that doesn't require a canary feature like useFormStatus. Use it as follows:

// [...]

export function Form() {
  const [pending, startTransaction] = useTransition();
  
  // [...]
  
  return (
    <form action={(formData) => startTransaction(() => formAction(formData))}>
      {pending && <span>Loading</span>}
    </form>
  );
}

src/app/form.tsx - don't forget to pass pending to your FormContent in order to disable the submit button

Bonus: Zod validation

Let's come to input validation with zod. The great thing here is that we can use the same validation functions on our client and our server action. If somehow your form gets submitted with invalid data we can use the same validation within our server action and check again with the same set of rules.

The first thing we're gonna need is an additional dependency to validate FormData (which, as mentioned earlier, is passed to our server actions). Gladly there's a library for this, zod-form-data:

npm i zod-form-data

Let's then create a simple validation.ts file where our validation takes place:

import { zfd } from "zod-form-data";
import { z } from "zod";

export const formSchema = zfd.formData({
  firstName: zfd.text(z.string().min(2, "Too short").max(20, "Too long")),
  lastName: zfd.text(
    z.string().min(2, "Too short").max(20, "Too long").optional(),
  ),
});

src/app/validation.ts

Our validation is simple: firstName is required, has to have at least 2 characters and a maximum of 20 characters. lastName is optional and follows the same character count rules.

Now let's add our validation on our client- and server side.

Client-side

In order to use input validation on our client-side all we need to do is to add a proper resolver to our useForm hook in our Form component:

import { formSchema } from "@/app/validation";
import { zodResolver } from "@hookform/resolvers/zod";

// [...]

export function Form() {
  const {
      register,
      formState: { isValid },
  } = useForm<FormValues>({
    mode: "all",
    resolver: zodResolver(formSchema),
  });
  
  // [...]
}

src/app/form.tsx

isValid will no longer be true as long as we do not adhere to the rules defined in our validation schema. Simply pass isValid to our FormContent in order to disable the submit button.

We come to showing errors in a bit, but first let's add this validation to our server:

Server-side

To our server-side validation we need to call the parse method of our formSchema and pass all our form data to it. Additionally, we want to extend our State type to add an error status:

// [...]

import { formSchema } from "@app/validation";
import { ZodError } from "zod";

// [...]

export type State =
  | {
      status: "success";
      message: string;
    }
  | {
      status: "error";
      message: string;
      errors?: Array<{
        path: string;
        message: string;
      }>;
    }
  | null;

export async function getFullName(
  prevState: State | null,
  data: FormData,
): Promise<State> {
  try {
    // Artificial delay; don't forget to remove that!
    await new Promise((resolve) => setTimeout(resolve, 1000));

    // Validate our data
    const { firstName, lastName } = formSchema.parse(data);
    
    return {
      status: "success",
      message: `Welcome, ${firstName} ${lastName ? lastName : ""}!`,
    };
  } catch (e) {
    // In case of a ZodError (caused by our validation) we're adding issues to our response
    if (e instanceof ZodError) {
      return {
        status: "error",
        message: "Invalid form data",
        errors: e.issues.map((issue) => ({
          path: issue.path.join("."),
          message: `Server validation: ${issue.message}`,
        })),
      };
    }
    return {
      status: "error",
      message: "Something went wrong. Please try again.",
    };
  }
}

src/app/actions.ts

Depending our the validity of our data sent to our server action we're now gonna retrieve a proper error status that might include an array of form errors (like "Too short").

Error handling

Last but not least: error handling. We've done everything so far for a decent form, except showing the user where and why they messed up.

We're gonna use react-hook-forms capabilities here - but we need one last dependency for that: @hookform/error-message

npm i @hookform/error-message

This component is going to be used next to our inputs in order to show errors with their values.

But before we can show errors, we need to know that something bad happened. We'll do that the same way we've implemented the usage of our server action return value prior. Additionally, we're gonna use setError of react-hook-form to set errors accordingly.

// [...]

export function Form() {
  // [...]
  
  const {
    register,
    formState: { isValid, errors },
    setError
  } = useForm<FormValues>({
    mode: "all",
    resolver: zodResolver(formSchema),
  });
  
  useEffect(() => {
    if (!state) {
      return;
    }
    // In case our form action returns `error` we can now `setError`s
    if (state.status === "error") {
      state.errors?.forEach((error) => {
        setError(error.path as FieldPath<FormValues>, {
          message: error.message,
        });
      });
    }
    if (state.status === "success") {
      alert(state.message);
    }
  }, [state, setError]);
  
  // [...]
}

src/app/form.tsx

💡
To test for server-side only validation simply remove the resolver from useForm or remove any submit enabled flags.

Now that our errors are properly set we can add the error message component to our inputs in our FormContent component:

// [...]

import { ErrorMessage } from "@hookform/error-message";

function FormContent({
  register,
  isValid,
  errors,
}: {
  register: UseFormRegister<FormValues>;
  isValid: boolean;
  errors: FieldErrors<FormValues>;
}) {
  const { pending } = useFormStatus();

  return (
    <>
      <input {...register("firstName")} placeholder="First name" />
      <ErrorMessage name="firstName" errors={errors} />
      <input {...register("lastName")} placeholder="Last name" />
      <ErrorMessage name="lastName" errors={errors} />
      <input type="submit" disabled={pending || !isValid} />
      {pending && <span>Loading...</span>}
    </>
  );
}

// [...]

If you've disabled your client-side validation and now submit our form with invalid data we should be getting the proper error messages shown right next to our inputs.

Conclusion

What a ride it was, wasn't it?

If you've made it through this entire guide you can see what I meant in the beginning: while server actions are stable the developer experience involving them isn't the best for now.

This isn't exclusive to react-hook-form; we could remove it completely and still have to deal with canary features, using useEffect to react to action state changes, split our components just to know about loading states, etc..

While things like using canary features will get better over time, I'm curious about the future of server actions and their developer experience. Compared to other approaches, e.g. tRPC, it just doesn't feel good at the moment to use them.

Will it stay this way? We'll see.

Should you use this approach? If you're good with its dx; sure, there's nothing wrong about it. It's just a bit... rough for now.

Even if I spent a decent amount on researching this topic, chances are there are better or easier approaches to using server actions. If you think I missed something, know a better approach, or have feedback/questions on this post please let me know in the comments or write me a message.