9 January 2025

Simplifying React Hook Form and Next.js 15 Server Actions Integration

Ryan Knight

Ryan Knight

useActionState

If you're like me and love React Hook Form (RHF) but also want to leverage the latest Server Actions in Next.js 15 and React 19, you might have faced some challenges. As Server Actions continue to evolve, it can be hard to keep up with the best patterns for managing form state while maintaining clean and functional code.

In this post, I’ll share my solution for integrating React Hook Form with Next.js Server Actions. We’ll explore how I use Shadcn custom-controlled form fields, structure my forms, and manage actions. Most importantly, I’ll show you my custom hook that helps React Hook Form and Server Actions work seamlessly together—without losing the power of either.

If you would like you can checkout my working example here.

The Problem?

With Server Actions, creating a form is easy—you simply pass an action into the action attribute of the <form /> tag. The new useActionState hook will handle everything for you, which is great if you don’t need client-side validation. However, we also want to use Shadcn’s controlled form components, which offer error handling and better structure. Here’s a simple example of a form using a basic Server Action:

export default function Form() {
  const [state, action, isPending] = useActionState(action, initialState)

  return (
        <form action={action}>
          <div className="space-y-4">
            <div className="space-y-2">
              <Label htmlFor="name">Street Address</Label>
              <Input
                id="name"
                name="name"
                placeholder="John Doe"
                required
                minLength={3}
                maxLength={30}
                aria-describedby="name-error"
                className={state?.errors?.streetAddress ? 'border-red-500' : ''}
              />
              {state?.errors?.streetAddress && (
                <p id="name-error" className="text-sm text-red-500">
                  {state.errors.streetAddress[0]}
                </p>
              )}
            </div>
          {state?.message && (
            <Alert variant={state.success ? "default" : "destructive"}>
              {state.success && <CheckCircle2 className="h-4 w-4" />}
              <AlertDescription>{state.message}</AlertDescription>
            </Alert>
          )}

          <Button 
            type="submit" 
            disabled={isPending}
          >
            {isPending ? 'Saving...' : 'Save'}
          </Button>
        </form>
  )
}

While this works, it quickly becomes messy as your form grows. And importantly, this doesn’t give you client-side validation, which means your action will run even if required fields are in an error state.

The Controlled Components Approach

To use controlled components (such as those from Shadcn), we can make a reusable form input using React Hook Form's context provider. This approach allows us to use client-side validation while still integrating with our Server Actions.

Here’s an example of a controlled input:

* The Form components are from Shadcn Form that have RHF context built in.

const { control } = useFormContext();

    <FormField
      control={control}
      name={name}
      defaultValue={""}
      render={({ field }) => (
        <FormItem className={cn(rest.type, className)}>
          {label ? <FormLabel>{label}</FormLabel> : null}
          <FormControl>
            <Input className={inputClassName} {...field} {...rest} />
          </FormControl>
          <FormDescription>{description}</FormDescription>
          <FormMessage />
        </FormItem>

As long as this input is inside a <FormProvider />, it will work as expected. But how do we combine React Hook Form and Server Actions?

Integrating React Hook Form with Server Actions

The key to combining React Hook Form with Server Actions lies in using a ref and the onSubmit function. Here's how we can do it:

  • Use a ref to track the values of the <form />.
  • Trigger both the React Hook Form submission and the Server Action using onSubmit.

Here’s an example:

const formRef = useRef<HTMLFormElement>(null);
const [state, actionFn, isPending] = useActionState(action, initialState);

const form = useForm<z.output<typeof schema>>({
    resolver: zodResolver(schema),
    defaultValues: {
      ...defaultFormValues,
    },
  });

<Form {...form}>
  <form
    onSubmit={form.handleSubmit(action(new FormData(formRef.current!))}
    ref={formRef}
    className="flex gap-4 flex-col"
  >
    <FormInput
      placeholder="John Doe"
      name={"name"}
      label="Name"
    />
    <Button type="submit" className="w-full mt-4">
      Save Address
    </Button>
  </form>
</Form>

The way we get React Hook Form to get our form values is simply calling form.handleSubmit as usual but to get our server action to recieve our data we have to use our ref.

form.handleSubmit(action(new FormData(formRef.current!))

The Async Context Issue

This almost works, but there’s a catch. If you don’t use the action in the action attribute of your <form />, you’ll lose the async context, and React will throw an error:

An async function was passed to useActionState, but it was dispatched outside of an action context. This is likely not what you intended. Either pass the dispatch function to an `action` prop, or dispatch manually inside `startTransition`

To fix this, we need to ensure that our action is run within the right context. Here's how to solve it:

import { startTransition } from "react";

const runAction = () =>
  startTransition(() => {
    action(new FormData(formRef.current!));
});

Now you onSubmit looks like this:

 onSubmit={form.handleSubmit(runAction)}

The useHookFormActionState Hook

To keep my forms clean and maintainable, I encapsulated both React Hook Form and useActionServer logic into a custom hook called useHookFormActionState. Here’s what it looks like:

import { type FormState } from "@/types/FormState";
import { zodResolver } from "@hookform/resolvers/zod";
import { startTransition, useActionState, useRef } from "react";
import { useForm } from "react-hook-form";
import { type z, type ZodType } from "zod";

const initialState = {
  success: false,
  message: "",
};

interface Props<T extends ZodType<any, any, any> = ZodType<any, any, any>> {
  action: (prevState: FormState, data: FormData) => Promise<FormState>;
  schema: T;
  defaultFormValues: z.infer<T>;
}

export const useHookFormActionState = <T extends ZodType<any, any, any>>({
  action,
  schema,
  defaultFormValues,
}: Props<T>) => {
  const formRef = useRef<HTMLFormElement>(null);
  const [state, actionFn, isPending] = useActionState(action, initialState);

  const form = useForm<z.output<typeof schema>>({
    resolver: zodResolver(schema),
    defaultValues: {
      ...defaultFormValues,
    },
  });

  /* 
    Need startTransition so that the async context can be picked up by useActionState.
    without it you will not get the isPending indication amongst other errors.
  */
  const runAction = () =>
    startTransition(() => {
      actionFn(new FormData(formRef.current!));
    });

  return { runAction, isPending, state, form, formRef };
};

At first glance, the props for this hook may seem intimidating, but let me walk you through how it works.

The useHookFormActionState hook is typed based on the Zod schema you provide. This allows you to get full TypeScript support, including IntelliSense and type safety based on your schema. The generic T extends ZodType, and with some TypeScript magic, I can infer the schema and default values.

Here’s a simplified breakdown of the hook:

interface Props<T extends ZodType<any, any, any> = ZodType<any, any, any>> {
  action: (prevState: FormState, data: FormData) => Promise<FormState>;
  schema: T;
  defaultFormValues: z.infer<T>;
}
  • T: Generic type, must extend ZodType (Zod schema).
  • action: Async function that updates the form state (prevState, data) and returns a new state (FormState).
  • schema: The Zod validation schema of type T.
  • defaultFormValues: Default form values, inferred from the schema type (z.infer<T>).

This hook significantly reduces boilerplate code while maintaining all the functionality you need.

onst formRef = useRef<HTMLFormElement>(null);
  const [state, actionFn, isPending] = useActionState(action, initialState);

  const form = useForm<z.output<typeof schema>>({
    resolver: zodResolver(schema),
    defaultValues: {
      ...defaultFormValues,
    },
  });

  /* 
    Need startTransition so that the async context can be picked up by useActionState.
    without it you will not get the isPending indication amongst other errors.
  */
  const runAction = () =>
    startTransition(() => {
      actionFn(new FormData(formRef.current!));
    });

  return { runAction, isPending, state, form, formRef };


Putting It All Together

By combining everything, you get a clean and simple form that integrates React Hook Form with Server Actions. Here's the result:

import { useHookFormActionState } from "@/hooks/useHookFormActionState";

const { runAction, state, isPending, form, formRef } = useHookFormActionState(
    {
      action: someAction,
      schema: someSchema,
      defaultFormValues: {
          name: "",
      },
    }
  );

  if (state.success) return <p>Success</p>;
  if (isPending) return <p>Pending</p>;

<Form {...form}>
  <form
     onSubmit={form.handleSubmit(runAction)}
    ref={formRef}
    className="flex gap-4 flex-col"
  >
    <FormInput
      placeholder="John Doe"
      name={"name"}
      label="Name"
    />
    <Button type="submit" className="w-full mt-4">
      Save Address
    </Button>
  </form>
</Form>

Since Shadcn’s form components already have a React Hook Form context, handling error messages is a breeze. The initial abstraction gives you a huge speed boost, allowing you to focus on the form’s schema, action, and markup without worrying about the integration details. The custom hook takes care of the rest.

Conclusion

By using useHookFormActionState, React Hook Form, and Server Actions together, you can easily create powerful, clean forms in Next.js 15. This approach ensures that you get the best of both worlds—client-side validation and server-side processing—while keeping your code clean, organized, and type-safe.