Let’s get into the Action, Server Action!

Alon Valadji
Israeli Tech Radar
Published in
6 min readJun 23, 2024

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.

--

--

Alon Valadji
Israeli Tech Radar

Alon is a leading software architect well-versed in Rozansky and Woods’ architectural methodologies. As a full-cycle JavaScript and web architecture torchbearer