DEV Community

Cover image for How to use react-hook-form with useActionState Hook in Nextjs15
Emmanuel Xs
Emmanuel Xs

Posted on

How to use react-hook-form with useActionState Hook in Nextjs15

TL;DR: Check out the complete code on my GitHub repository.


Why should you use react-hook-form with the useActionState hook

Handling forms in React often involves juggling client-side validation, server-side logic, and a seamless user experience. Enter useActionState, a powerful hook introduced in React to streamline server-side form submissions, and react-hook-form, a robust library for client-side form management. By combining these tools, you can build forms that are resilient, accessible, and user-friendly—even in scenarios where JavaScript is disabled.

Why Use Both?

You might wonder: if useActionState simplifies form handling and comes directly from React, why would we still need react-hook-form?.
The answer lies in the unique strengths of each tool:

  • react-hook-form excels at providing real-time client-side validation, improving UX by giving users instant feedback.
  • useActionState focuses on server-side validation and state persistence, ensuring functionality even with JavaScript disabled.

By combining both, you get the best of both worlds:

  • UAS handles server-side validation and state persistence.
  • RHF ensures smooth client-side validation and progressive enhancement.

This approach creates a more resilient and user-friendly form-handling solution.

Resources:


Prerequisite

This tutorial assumes you're using Next.js 15. If you haven't installed it yet, you can follow the instructions on the Next.js installation guide.

Below are the required tools and packages for this tutorial:

  • zod: For schema validation.
  • react-hook-form: To handle client-side form validation.
  • @hookform/resolvers: Integrates zod with react-hook-form for validation.
  • shadcn-ui (optional): Quickly sets up essential UI components. Check out their documentation for Next.js 15 .
    • For shadcn-ui, you'll need components such as:
    • input
    • button
    • card
    • Optional: the form components
  • Alternatively, you can build your own components if you prefer not to use shadcn-ui.
  • server-only: Ensures server actions are executed in server components, functions, or hooks that support them.

How to Use useActionState (UAS) for Server-Side Validation

Ensure you are familiar with useActionState. If not, refer to the official documentation.

Creating a Login Component

Run the following commands to generate a basic login component using shadcn-ui:

For npm:

npx shadcn@latest add login-01
Enter fullscreen mode Exit fullscreen mode

For pnpm:

pnpm dlx shadcn@latest add login-01
Enter fullscreen mode Exit fullscreen mode

For yarn:

yarn dlx shadcn@latest add login-01
Enter fullscreen mode Exit fullscreen mode

For bun:

bunx shadcn@latest add login-01
Enter fullscreen mode Exit fullscreen mode

Login Component Code

Below is the LoginForm component generated by shadcn-ui. If you're not using shadcn-ui, you can create a similar form manually.

import Link from "next/link";

import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

export function LoginForm() {
  return (
    <Card className="mx-auto max-w-sm">
      <CardHeader>
        <CardTitle className="text-2xl">Login</CardTitle>
        <CardDescription>
          Enter your email below to login to your account
        </CardDescription>
      </CardHeader>
      <CardContent>
        <div className="grid gap-4">
          <div className="grid gap-2">
            <Label htmlFor="email">Email</Label>
            <Input
              id="email"
              type="email"
              placeholder="m@example.com"
              required
            />
          </div>
          <div className="grid gap-2">
            <div className="flex items-center">
              <Label htmlFor="password">Password</Label>
              <Link href="#" className="ml-auto inline-block text-sm underline">
                Forgot your password?
              </Link>
            </div>
            <Input id="password" type="password" required />
          </div>
          <Button type="submit" className="w-full">
            Login
          </Button>
          <Button variant="outline" className="w-full">
            Login with Google
          </Button>
        </div>
        <div className="mt-4 text-center text-sm">
          Don&apos;t have an account?{" "}
          <Link href="#" className="underline">
            Sign up
          </Link>
        </div>
      </CardContent>
    </Card>
  );
}
Enter fullscreen mode Exit fullscreen mode

The above LoginForm component is a form with inputs for email and password, serving as the starting point for this tutorial. It provides a basic structure that can be customized or extended as needed for the application's requirements.

Rendering the Component

If you are not using shadcn-ui, Create a folder named login, then inside it, create a page.tsx file with the following code and import the form you created:

import { LoginForm } from "@/components/login-form"

export default function Page() {
  return (
    <div className="flex h-screen w-full items-center justify-center px-4">
      <LoginForm />
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Adding Schema Validation with zod

Create a file named auth-validation.ts to define the schema for form validation:

import { z } from "zod";

export const loginSchema = z.object({
  email: z.string().trim().min(2, "2 or more char").email(), // Trimming to remove unwanted spaces
  password: z.string().trim().min(8, "min char is 8"), // Trimming to remove unwanted spaces
});
Enter fullscreen mode Exit fullscreen mode

The above code is used to validate user inputs from the form, check out zod docs for more info.

Server-Side Validation

Create a server action to validate form data. Use the schema defined above for validation.

"use server";
import "server-only";
import { loginSchema } from "./auth-validation";

type FormState = {
  success: boolean;
  fields?: Record<string, string>;
  errors?: Record<string, string[]>;
};

export async function loginAction(
  prevState: FormState,
  payload: FormData
): Promise<FormState> {
  console.log("payload received", payload);

  if (!(payload instanceof FormData)) {
    return {
      success: false,
      errors: { error: ["Invalid Form Data"] },
    };
  }
  // Here, we use `Object.fromEntries(payload)` to convert the `FormData` object into a plain object. This allows us to work with the data in a format that the zod schema understands.

  const formData = Object.fromEntries(payload);
  console.log("form data", formData);

  const parsed = loginSchema.safeParse(formData);

  if (!parsed.success) {
    const errors = parsed.error.flatten().fieldErrors;
    const fields: Record<string, string> = {};

    for (const key of Object.keys(formData)) {
      fields[key] = formData[key].toString();
    }
    console.log("error returned data", formData);
    console.log("error returned error", errors);
    return {
      success: false,
      fields,
      errors,
    };
  }

  if (parsed.data.email === "test@example.com") {
    return {
      success: false,
      errors: { email: ["email already taken"] },
      fields: parsed.data,
    };
  }
  console.log("parsed data", parsed.data);
  return {
    success: true,
  };
}
Enter fullscreen mode Exit fullscreen mode

The loginAction function is designed to be used with React's useActionState hook, which facilitates managing form submission and server-side validation seamlessly. Here's a brief explanation:

  1. Input Handling: The function takes the previous form state (prevState) and new form data (payload) submitted by the user. It ensures the payload is a valid FormData object.

  2. Validation: It uses zod's loginSchema to validate the form data. If validation fails, it extracts the error messages and prepares the fields and errors objects to provide feedback to the user.

  3. Simulated Check: It includes a sample condition to reject an email (test@example.com) to demonstrate additional validation logic beyond schema validation that can come from the server.

  4. Return Structure: The function returns a FormState object containing:

    • success: Whether the form submission was successful.
    • fields: The user's submitted data, for populating inputs on error.
    • errors: Validation and other errors from the server, for displaying feedback.

Adding useActionState

"use client";
import Link from "next/link";

import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useActionState } from "react";
import { loginAction } from "@/app/login/action";

export function LoginForm() {
  const [formState, formAction] = useActionState(loginAction, {
    success: false,
  });

  console.log(formState);
  console.log("fields returned: ", { ...(formState?.fields ?? {}) });

  return (
    <Card className="mx-auto max-w-sm">
      <CardHeader>
        <CardTitle className="text-2xl">Login</CardTitle>
        <CardDescription>
          Enter your email below to login to your account
        </CardDescription>
      </CardHeader>
      <CardContent>
        <form action={formAction} className="grid gap-4">
          <div className="grid gap-2">
            <Label htmlFor="email">Email</Label>
            <Input
              name="email"
              id="email"
              type="email"
              placeholder="m@example.com"
              required
              defaultValue={formState.fields?.email}
            />
            {formState?.errors?.email && (
              <p className="text-destructive">{formState?.errors?.email}</p>
            )}
          </div>
          <div className="grid gap-2">
            <div className="flex items-center">
              <Label htmlFor="password">Password</Label>
              <Link href="#" className="ml-auto inline-block text-sm underline">
                Forgot your password?
              </Link>
            </div>
            <Input
              name="password"
              id="password"
              type="password"
              required
              defaultValue={formState.fields?.password}
            />
            {formState?.errors?.password && (
              <p className="text-destructive">{formState?.errors?.password}</p>
            )}
          </div>
          <Button type="submit" className="w-full">
            Login
          </Button>
          <Button variant="outline" className="w-full">
            Login with Google
          </Button>
        </form>
        <div className="mt-4 text-center text-sm">
          Don&apos;t have an account?{" "}
          <Link href="#" className="underline">
            Sign up
          </Link>
        </div>
      </CardContent>
    </Card>
  );
}

Enter fullscreen mode Exit fullscreen mode

This LoginForm component uses the useActionState hook to manage server-side validation and form submission with the help of the server acrion loginAction. Here's a brief explanation:

  1. Form State Management:
    The useActionState hook is used to manage the form state (formState) and handle form submission (formAction). The state tracks the success of the form submission, any input errors, and retains the field values in case of validation failure.

  2. Dynamic Error Feedback:

    • The formState.errors object holds validation errors for each field, such as invalid email formats or missing passwords.
    • Errors are displayed below the relevant input fields dynamically.
  3. Rehydrating Input Fields:
    The defaultValue property is used to repopulate input fields with the last submitted values (formState.fields), ensuring users don't lose their data upon validation failure.

  4. Client-Side or Default Validation:
    HTML required attributes ensure the form cannot be submitted with empty values, and also works seamlessly even when JavaScript is disabled.

  5. Submission Logic:
    The formAction from the useActionState hook is being passed to the form element through it's action attribute which ensures that the formData is submitted whenever the form is submitted.

  6. Accessibility and User Feedback:
    Even when JavaScript is disabled, the form element's action attribute ensures the form submission will work since since it is being handled by the browser and not JavaScript.

Testing with JavaScript Disabled

To test the form submission with JavaScript disabled:

  • Open the browser inspector with Ctrl + Shift + I.
  • Run Ctrl + Shift + P and search for Disable JavaScript.
  • Submit the form and observe how it works without client-side validation.
  • Run Ctrl + Shift + P and search for Enable JavaScript to enable JavaScript back.

Adding react-hook-form (RHF)

Create a new file and copy and paste the code from the LoginForm Component so that we can modify it or paste the code below.

"use client";
import Link from "next/link";

import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { startTransition, useActionState, useEffect, useRef } from "react";
import { loginAction } from "@/app/login/action";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { loginSchema } from "@/app/login/auth-validation";

export function LoginForm() {
  const [formState, formAction] = useActionState(loginAction, {
    success: false,
  });

  const formRef = useRef<HTMLFormElement>(null);

  const {
    register,
    handleSubmit,
    reset,
    formState: { errors: rhfErrors, isSubmitSuccessful },
  } = useForm<z.output<typeof loginSchema>>({
    resolver: zodResolver(loginSchema),
    defaultValues: {
      email: "",
      password: "",
      ...(formState?.fields ?? {}),
    },
    mode: "onTouched",
  });

  console.log(formState);
  console.log("fields returned: ", { ...(formState?.fields ?? {}) });

  useEffect(() => {
    if (isSubmitSuccessful && formState.success) {
      reset();
    }
  }, [reset, isSubmitSuccessful, formState.success]);

  return (
    <Card className="mx-auto max-w-sm">
      <CardHeader>
        <CardTitle className="text-2xl">Login</CardTitle>
        <CardDescription>
          Enter your email below to login to your account
        </CardDescription>
      </CardHeader>
      <CardContent>
        <form
          ref={formRef}
          action={formAction}
          onSubmit={(evt) => {
            evt.preventDefault();
            handleSubmit(() => {
              startTransition(() => formAction(new FormData(formRef.current!)));
            })(evt);
          }}
          className="grid gap-4"
        >
          <div className="grid gap-2">
            <Label htmlFor="email">Email</Label>
            <Input
              id="email"
              type="email"
              placeholder="m@example.com"
              required
              defaultValue={formState.fields?.email}
              {...register("email")}
            />
            {formState?.errors?.email && (
              <p className="text-destructive">{formState?.errors?.email}</p>
            )}
            {rhfErrors.email?.message && (
              <p className="text-destructive">{rhfErrors.email?.message}</p>
            )}
          </div>
          <div className="grid gap-2">
            <div className="flex items-center">
              <Label htmlFor="password">Password</Label>
              <Link href="#" className="ml-auto inline-block text-sm underline">
                Forgot your password?
              </Link>
            </div>
            <Input
              id="password"
              type="password"
              required
              defaultValue={formState.fields?.password}
              {...register("password")}
            />
            {formState?.errors?.password && (
              <p className="text-destructive">{formState?.errors?.password}</p>
            )}
            {rhfErrors.password?.message && (
              <p className="text-destructive">{rhfErrors.password?.message}</p>
            )}
          </div>
          <Button type="submit" className="w-full">
            Login
          </Button>
          <Button variant="outline" className="w-full">
            Login with Google
          </Button>
        </form>
        <div className="mt-4 text-center text-sm">
          Don&apos;t have an account?{" "}
          <Link href="#" className="underline">
            Sign up
          </Link>
        </div>
      </CardContent>
    </Card>
  );
}
Enter fullscreen mode Exit fullscreen mode

Explanation of the Updated LoginForm Component

This version of the LoginForm component integrates react-hook-form (RHF) for enhanced client-side form validation and combines it with server-side validation via useActionState (UAS). Here's a detailed breakdown:

  1. Form Reference (formRef):
    The formRef is used to access the form DOM element which is passed to the handleSubmit function for validation after transforming its values to a FormData object,.

  2. Form Submission Workflow:

    • Prevent Default Behavior: The form's default behavior (navigating to a new URL) is prevented using evt.preventDefault().
  • Client-Side Validation:
    RHF's handleSubmit intercepts the form submission to perform client-side validation defined in the zodResolver.

  • Start Transition:
    The formAction function (provided by UAS) submits the validated FormData to the server action (loginAction).

Why Use startTransition?

React requires server actions (formAction) to be called either inside a form's action attribute or wrapped in startTransition. Without startTransition, React throws an error like:

"An async function was passed to useActionState, but it was dispatched outside of an action context." Wrapping the server action in startTransition ensures the submission logic runs in a non-blocking manner".

  1. Client-Side and Server-Side Validation:
    • RHF Validation: RHF validates the inputs on the client side using the Zod schema (loginSchema) and displays dynamic error messages (rhfErrors).
  • Server-Side Validation: The server validates the form data using the formAction from useActionState. Server-side errors (formState.errors) are displayed alongside client-side errors if they exist.
  1. Handling Default Values:

    • defaultValues in RHF's useForm hook ensures input fields are pre-populated with the last submitted values (formState.fields) after a validation error, improving the user experience.
    • The defaultValue attribute on individual input fields ensures form functionality even if JavaScript is disabled or fails to load
  2. Reset Form on Success:
    The useEffect hook resets the form fields when both client-side validation and server-side submission are successful (isSubmitSuccessful and formState.success).


Caveats

While the code and approach discussed above offer a robust starting point, there are a few limitations worth noting:

  1. Compatibility with the New React Compiler
    When used with the new ReactCompiler, the form behaves unexpectedly—it submits successfully only once, then displays all errors from react-hook-form (RHF). To address this, consider using the "use no memo" annotation to prevent unnecessary memoization of the form state.

  2. Potential Overlap with Future RHF Features
    RHF may eventually integrate similar functionality natively. Given the simplicity of the current submission logic, this may become a less manual process in future updates. Keeping an eye on RHF’s roadmap could help you avoid redundant patterns.


Can This Be Combined with react-query?

Yes, combining react-query, react-hook-form, and useActionState is not only possible but could result in a cleaner and more streamlined form management system. For instance:

  • react-query’s useMutation hook provides powerful callbacks (onSuccess, onError, etc.) that can simplify form submission logic.
  • By leveraging useMutation, you can eliminate the need for a separate useEffect to track the submission lifecycle, allowing for a more declarative approach to managing server-side actions.

However, while this combination is technically feasible, it’s worth considering whether using all three tools simultaneously is overkill for your specific use case. Balancing simplicity with functionality is key to maintaining manageable code.


Conclusion and Acknowledgments

This approach is largely inspired by the work of Jack Herrington, whose insights and examples helped shape this implementation. I encourage you to check out his content for more advanced use cases:

Thank you for reading to the end! If you encounter any issues or have questions, feel free to share them in the comments section below. I’d love to hear your feedback and suggestions.

Top comments (0)