Understanding Server Actions Through Forms

A current look at Server Components, Server Actions, forms, pending UI, optimistic updates, and cache invalidation.

Published
July 29, 2023
日本語で読む

Write <form action={createArticle}> and the form submits without a single API Route. It reads like a function call, and it is server work that crosses a network boundary.

I first tried Server Actions in a proof of concept built with Next.js and GraphQL, back when the feature was experimental, and later spoke about what I had learned. React and Next.js have changed since that talk, so the examples here use the current APIs.

A quick review of Server Components

Pages and layouts in the App Router are Server Components by default. They run on the server, so data access and rendering logic do not have to become part of the client-side JavaScript bundle.

export default async function Page() {
  const articles = await fetchArticles();

  return (
    <main>
      <ArticleList articles={articles} />
      <FavoriteButton />
    </main>
  );
}

The server sends an RSC payload and HTML to the client. Only the parts that need state, event handlers, effects, or browser APIs such as window and localStorage become Client Components with "use client".

"use client";

export function FavoriteButton() {
  return <button onClick={() => {}}>Favorite</button>;
}

Server Components are a way to choose boundaries. Data and static structure stay on the server, and small client boundaries go around the parts that need interaction. Moving everything to the server is not the point.

What is a Server Action?

A Next.js Server Action is an asynchronous server function that can be invoked from the client. It can handle a form submission or mutation without requiring a manually created API Route.

export default function Page() {
  async function createArticle(formData: FormData) {
    "use server";

    const title = formData.get("title");
    await saveArticle({ title });
  }

  return (
    <form action={createArticle}>
      <input name="title" />
      <button type="submit">Create</button>
    </form>
  );
}

Passing an Action to the action prop of a form sends the fields as FormData. The form can submit before JavaScript loads, which makes the model a good fit for progressive enhancement.

A Server Action is still exposed server behavior. Never trust its arguments. Authentication, authorization, validation, and error handling belong inside the server boundary.

Calling an Action from a Client Component

Placing "use server" at the top of a file makes its exported server functions available to Client Components.

// actions.ts
"use server";

export async function updateFavorite(id: string, favorite: boolean) {
  await saveFavorite({ id, favorite });
}
// favorite-button.tsx
"use client";

import { startTransition } from "react";
import { updateFavorite } from "./actions";

export function FavoriteButton({ id, favorite }) {
  return (
    <button
      onClick={() => {
        startTransition(async () => {
          await updateFavorite(id, !favorite);
        });
      }}>
      Favorite
    </button>
  );
}

The call looks like an ordinary async function, but it still crosses a network boundary. The UI must account for pending work, failure, repeated clicks, and stale responses.

Showing pending state

The React useActionState Hook exposes both the result of a form Action and whether it is pending. That supports inline validation messages and duplicate-submission prevention.

"use client";

import { useActionState } from "react";
import { createArticle } from "./actions";

const initialState = { message: "" };

export function ArticleForm() {
  const [state, formAction, isPending] = useActionState(
    createArticle,
    initialState,
  );

  return (
    <form action={formAction}>
      <input name="title" />
      <button disabled={isPending}>
        {isPending ? "Creating…" : "Create"}
      </button>
      <p aria-live="polite">{state.message}</p>
    </form>
  );
}

Return expected validation failures from the Action and show them in the form so the user can recover in place. Unexpected exceptions belong in an Error Boundary.

Optimistic updates

For an operation that is likely to succeed and safe to reverse, such as toggling a favorite, useOptimistic can show the expected result before the server responds.

"use client";

import { startTransition, useOptimistic } from "react";
import { updateFavorite } from "./actions";

export function FavoriteButton({ id, favorite }) {
  const [optimisticFavorite, setOptimisticFavorite] = useOptimistic(favorite);

  function handleClick() {
    startTransition(async () => {
      setOptimisticFavorite((current) => !current);
      await updateFavorite(id, !favorite);
    });
  }

  return (
    <button onClick={handleClick} aria-pressed={optimisticFavorite}>
      {optimisticFavorite ? "Favorited" : "Favorite"}
    </button>
  );
}

An optimistic favorite button

Use optimistic UI only when failure can be reversed safely. Payment and inventory reservation should not appear final before the server confirms success.

Invalidating cached data

After a mutation, any cached view of that data may also need to change. The right Next.js strategy depends on the rendering model and whether Cache Components are enabled. The old assumption that every GET fetch is cached by default no longer applies universally.

Cached data can be tagged, then updated or revalidated after a Server Action. revalidatePath is also available for path-based invalidation.

"use server";

import { updateTag } from "next/cache";

export async function updateFavorite(id: string, favorite: boolean) {
  await saveFavorite({ id, favorite });
  updateTag("favorites");
}

updateTag is designed for read-your-own-writes behavior in a Server Action. revalidateTag is useful when stale data may be served and refreshed on a later visit, including for other viewers. Before introducing a cache, decide which data can be reused, for how long, and for whom.

Closing note

Server Components and Server Actions keep data access and mutation close to the server while adding client-side interaction only where it is needed. Forms, pending state, optimistic UI, and cache invalidation fit into one model.

As in the opening, the function-call syntax does not eliminate authentication, validation, failure handling, concurrency, or cache design. The API Route is no longer visible, which makes it easier to forget that the network boundary is still there.

References