How to use react-hook-form with useActionState Hook in Nextjs15
Emmanuel Xs
Posted on November 28, 2024
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:
- Learn more about useActionState.
- Explore the documentation for react-hook-form
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
: Integrateszod
withreact-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
- For
- 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
For pnpm:
pnpm dlx shadcn@latest add login-01
For yarn:
yarn dlx shadcn@latest add login-01
For bun:
bunx shadcn@latest add login-01
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't have an account?{" "}
<Link href="#" className="underline">
Sign up
</Link>
</div>
</CardContent>
</Card>
);
}
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>
)
}
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
});
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,
};
}
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:
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.
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.
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.
-
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't have an account?{" "}
<Link href="#" className="underline">
Sign up
</Link>
</div>
</CardContent>
</Card>
);
}
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:
Form State Management:
TheuseActionState
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.-
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.
- The
Rehydrating Input Fields:
ThedefaultValue
property is used to repopulate input fields with the last submitted values (formState.fields
), ensuring users don't lose their data upon validation failure.Client-Side or Default Validation:
HTMLrequired
attributes ensure the form cannot be submitted with empty values, and also works seamlessly even when JavaScript is disabled.Submission Logic:
TheformAction
from theuseActionState
hook is being passed to theform
element through it's action attribute which ensures that the formData is submitted whenever the form is submitted.Accessibility and User Feedback:
Even when JavaScript is disabled, theform
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't have an account?{" "}
<Link href="#" className="underline">
Sign up
</Link>
</div>
</CardContent>
</Card>
);
}
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:
Form Reference
(formRef)
:
The formRef is used to access theform
DOM element which is passed to thehandleSubmit
function for validation after transforming its values to aFormData
object,.-
Form Submission Workflow:
-
Prevent Default Behavior:
The form's default behavior (navigating to a new URL) is prevented using
evt.preventDefault()
.
-
Prevent Default Behavior:
The form's default behavior (navigating to a new URL) is prevented using
Client-Side Validation:
RHF'shandleSubmit
intercepts the form submission to perform client-side validation defined in thezodResolver
.Start Transition:
TheformAction
function (provided by UAS) submits the validatedFormData
to the server action (loginAction
).
Why Use startTransition?
React requires server actions (
formAction
) to be called either inside aform's
action attribute or wrapped instartTransition
. WithoutstartTransition
, 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".
- 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
fromuseActionState
. Server-side errors (formState.errors) are displayed alongside client-side errors if they exist.
-
Handling Default Values:
-
defaultValues
in RHF'suseForm
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
-
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:
Compatibility with the New React Compiler
When used with the newReactCompiler
, the form behaves unexpectedly—it submits successfully only once, then displays all errors fromreact-hook-form
(RHF). To address this, consider using the"use no memo"
annotation to prevent unnecessary memoization of the form state.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 separateuseEffect
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.
Posted on November 28, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.