Let’s get into the Action, Server Action!
tl;dr here is the github repository with the code: https://github.com/alonronin/server-actions
It was the year of Server Components, with a lot of adoption of the server-first paradigm. Having a way to run our components on the server and send them back to the client with our data is great, but we also need to be able to mutate them.
React introduces us to Server Actions, which allows us to run mutations on the server directly from our client app.
Let’s start by understanding what an action is.
Client Action
Up until now, we had to use an onSubmit
event to interact with the form, validate its data, and send it back to the server.
React’s new action allows us to still do that but in a much cleaner way without having to deal with client events like onSubmit
, preventDefault
behavior, etc.
Form elements can now use the attribute called “action” and instead of a url pass it a function that takes FormData
as an argument.
"use client";
import { Input } from "@/components/input";
import { Button } from "@/components/button";
import { Form } from "@/components/form";
export function UserForm() {
function handleSubmit(formData: FormData) {
const data = Object.fromEntries(formData);
console.log(data);
}
return (
<Form action={handleSubmit}>
<Input type="text" name="name" placeholder="Name" />
<Input type="email" name="email" placeholder="Email" />
<Button type="submit">Submit</Button>
</Form>
);
}
I’ll introduce some new hooks later on, but this is just like managing the local state of a client component.
Simple Server Action
With frameworks that implement Server Actions, like Next.js, we can run those actions on our server, using the client context without any code leaking to the client.
The action function needs a “use server”
annotation.
import { Input } from "@/components/input";
import { Button } from "@/components/button";
import { Form } from "@/components/form";
export function UserForm() {
function handleSubmit(formData: FormData) {
"use server";
const data = Object.fromEntries(formData);
console.log(data);
// insert user data to the db
redirect("/");
}
return (
<Form action={handleSubmit}>
<Input type="text" name="name" placeholder="Name" />
<Input type="email" name="email" placeholder="Email" />
<Button type="submit">Submit</Button>
</Form>
);
}
We also benefit from Next.js being nicely progressive out of the box, so our code still works even with JavaScript disabled.
Server Action and useFormStatus
Is there any way to let the user know the process is pending? React gives us a hook called useFormStatus
that we can import from react-dom
.
It works by extracting the submit button to its own client component, and then using the hook to get the pending state.
"use client";
import { useFormStatus } from "react-dom";
import { Button } from "@/components/button";
export function Submit() {
const { pending } = useFormStatus();
return (
<Button disabled={pending} type="submit">
Submit
</Button>
);
}
import { Input } from "@/components/input";
import { Form } from "@/components/form";
import { Submit } from "@/components/submit";
export function UserForm() {
function handleSubmit(formData: FormData) {
"use server";
const data = Object.fromEntries(formData);
console.log(data);
// insert user data to the db
redirect("/");
}
return (
<Form action={handleSubmit}>
<Input type="text" name="name" placeholder="Name" />
<Input type="email" name="email" placeholder="Email" />
<Submit />
</Form>
);
}
Extract an Action to a File
If we want to extract the actions, it’s best to put them in an actions.ts
file. Then we can annotate the entire file with “use server”
at the top, and now we don’t have to annotate each function individually.
"use server";
export function addUserAction(formData: FormData) {
const data = Object.fromEntries(formData);
console.log(data);
// insert user data to the db
redirect("/");
}
import { Input } from "@/components/input";
import { Form } from "@/components/form";
import { Submit } from "@/components/submit";
import { addUserAction } from "@/actions";
export function UserForm() {
return (
<Form action={addUserAction}>
<Input type="text" name="name" placeholder="Name" />
<Input type="email" name="email" placeholder="Email" />
<Submit />
</Form>
);
}
Everything runs as expected because the compiler understands this import.
Import Action to a Client Form Component
It’s easy to use a Server Action with a server component, but you can also import an action directly to a client component and use it as expected.
"use client";
import { Form } from "@/components/form";
import { Input } from "@/components/Input";
import { Submit } from "@/components/submit";
import { addUserAction } from "@/actions";
export function UserForm() {
return (
<Form action={addUserAction}>
<Input name="name" type="text" placeholder="Name" />
<Input name="email" type="email" placeholder="Email" />
<Submit>Submit</Submit>
</Form>
);
}
Server Action and useFormState
The new useFormState
hook allows us to get the state returned from the Server action and also supply initial state to the Client Component when using a Server Action in a client component.
With this approach, the action’s function signature changes, and now it takes another argument, prevState
. Also, we need to return a new state every time.
The content also needs to be refreshed ourselves, since we can’t redirect from the server action back to our page.
"use client";
import { Input } from "@/components/input";
import { Form } from "@/components/form";
import { Submit } from "@/components/submit";
import { addUserWithStateAction } from "@/actions";
import { useFormState } from "react-dom";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export function UserForm() {
const router = useRouter();
const [state, action] = useFormState(addUserWithStateAction, {});
useEffect(() => {
if (state.success) router.refresh();
}, [state.success]);
return (
<>
{state.errors && (
<div className="bg-red-100 border-red-800 text-red-800 p-4 rouned">
<ul className="felx felx-col">
{state.errors.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
</div>
)}
<Form action={action}>
<Input type="text" name="name" placeholder="Name" />
<Input type="email" name="email" placeholder="Email" />
<Submit />
</Form>
</>
);
}
export async function addUserWithStateAction(
prevState: any,
formData: FormData,
): Promise<{
success?: boolean;
errors?: string[];
}> {
const name = formData.get("name");
const email = formData.get("email");
if (!name || !email) {
return {
errors: ["Name is required", "Email is required"],
};
}
try {
// insert user data to the db
} catch (error) {
if (error) return { errors: [error.toString()] };
}
return { success: true };
}
Next.js Framework covers us with JavaScript disabled at the browser too.
Different actions on the same Form and additional parameters
We have multiple records from the server, and we want to delete each one with a Server Action. We can loop them and add a form for each record that dispatches a Server Action with a hidden input.
We can also compose Server Actions with other actions and pass additional arguments.
By using the bind
method on the Server Action, we can add an id
argument to the action and use the same form for submitting the action with the formAction
attribute.
import { removeUserAction } from "@/actions";
import { remove } from "@jridgewell/set-array";
export default async function Page() {
const users = await fetch(`${process.env.API_URL}/users`, {
cache: "no-cache",
}).then((res) => res.json());
return (
<>
<h1 className="text-4xl font-bold">
Users
</h1>
<form>
{users.map((user: { id: string; name: string; email: string }) => {
const action = removeUserAction.bind(null, user.id);
return (
<div key={user.id} className="flex gap-2 items-center">
<button
formAction={action}
className="hover:bg-red-300 bg-red-100 text-red-800 border-red-800 border rounded px-4 py-2 font-bold"
>
X
</button>
<p>{user.name}</p>
<p>({user.email})</p>
</div>
);
})}
</form>
<UserForm />
</>
);
}
"use server";
export async function removeUserAction(userId: string) {
// delete user from db
await fetch(`${process.env.API_URL}/users/${userId}`, {
method: "DELETE",
});
redirect("/users");
}
Non-Form Server Actions
When we import a Server Action, we can invoke it whenever we want, without even having a Form element. We can also wrap it in a useTransition
hook and have more control over our interactivity.
There’s only one caveat: You have to have JavaScript enabled in your browser to use this method.
"use client";
import { Button } from "@/components/button";
import { usePathname } from "next/navigation";
import { addLikesAction } from "@/actions";
import { useTransition } from "react";
export function Likes({ likes }: { likes: any[] }) {
const pathname = usePathname();
const [pending, startTransition] = useTransition();
return (
<Button
disabled={pending}
onClick={() =>
startTransition(async () => await addLikesAction({ url: pathname }))
}
>
Likes {likes.length}
</Button>
);
}
Hopefully you enjoyed this article, I’d love to hear your thoughts and read your comments. I appreciate you taking the time to read it.