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:
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.
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
:
We can then simply use our Form
component in our Home
component (that's still a server component):
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:
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:
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:
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:
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:
Additionally, we need to adjust the signature of our server action and add a utility type for our State
:
In order to use our return value now we can simply add a useEffect
that listens to changes of state
:
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:
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:
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:
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:
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:
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.
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.