ファランクスブログ

© 2026 all rights reserved.
  1. blog
  2. nextjs-server-action
  • 勘違いしていた点
  • 定義
  • onClick
  • Nextjs
  • Tanstack-Start
  • ローディングUI
  • useActionState()
  • useFormStatus()
  • *useTransition()
  • useOptimistic

Nextjs/Tanstack:Server-Action

2026年4月30日

勘違いしていた点

定義

formタグのaction属性に"use server"を書いた関数を渡す仕組みのことをserverActionと呼ぶと思っていたが、「クライアントから呼び出してサーバーで実行できる関数」のことをserverActionと言う。つまりformタグに限定されるものではない。

SeverActionのメリット:

  • API Routeを作らずにサーバーサイド処理が書ける

  • クライアント側からサーバーサイドで処理される関数を呼べる

    • 例えばPrismaはクライアントコンポーネントでは使えないのでuseEffect()内のfetchからroute.tsを経由するような形で行っていたが、serverActionを使えばbuttonやformからPrismaを使った処理を実行できる。

仕組み:

  • 内部的にはAPI RouteのようにHTTPリクエストを行う。サーバー側にロジックは閉じられている。

    • *HTTPリクエストができるということはエンドポイントでもあるので、認証やバリデーションはデフォルトで行う意識が必要

onClick

上記に書いたように、formタグ経由でなくてもaction関数を呼ぶことができる。

// actions.ts
"use server";

import { prisma } from "@/lib/prisma";
import { revalidateTag } from "next/cache";

export async function createPost(title: string) {
  const post = await prisma.post.create({
    data: { title },
  });
  updateTag("posts");
  return post;
}

export async function deletePost(id: number) {
  await prisma.post.delete({
    where: { id },
  });
  updateTag("posts");
}
// クライアントコンポーネント
"use client";

import { createPost, deletePost } from "@/lib/actions/post";
import { useTransition } from "react";

export function PostButtons({ id }: { id: number }) { 
  const [isPending, startTransition] = useTransition();

  return (
    <>
      <button
        onClick={() =>
          startTransition(() => {
            createPost("New Post");
          })
        }
      >
        Add
      </button>

      <button
        disabled={isPending}
        onClick={() =>
          startTransition(() => {
            deletePost(id);
          })
        }
      >
        Delete
      </button>
    </>
  );
}

Nextjs

formタグのactionの基本形:

      <form action={createPost}>
        <input type="text" placeholder="Title" name="title" />
        <textarea placeholder="Content" name="content" />

        <button type="submit" className="shadow-lg">
          Create Post
        </button>
      </form>
  • action関数は action.tsファイル等に切り分けるが、action.ts の上部に"use server" をつけないとエラーが出る。

Tanstack-Start

基本形:

export const updateTop = createServerFn({
  method: "POST",
})
  .inputValidator(
    z.object({
      Code: z.string(),
    }),
  )
  .handler(async ({ data: { Code } }) => {
 return 
}
  • 引数を受け取る場合は .inputValidatorを挟む。

  • 認証が必要なら .middlewareも挟む。

  • .handlerでreturnが無いとエラーになる。

ローディングUI

serverActionに使えるフック:

  • useActionState

    • pendingが分かる。

    • 結果も持てる。

    • formタグ以外で使える。

  • useFormStatus

    • pendingが分かる。

    • formタグ専用

useActionState()

  const [state, formAction, pending] = useActionState(createUser, initialState)

actionが返す内容とUI側の初期値の型構造が違うと型エラーになるので同じ構造を用意しないといけない。

import { createMemo } from "@/lib/actions";

  const [state, formAction, pending] = useActionState(createMemo, {
    title: "",
    content: "",
    message: "",
    status: "idle",
  });

      <form action={formAction}>
        <input type="text" name="title" />
        <input type="text" name="content" />
        <button
          type="submit"
          disabled={pending}
          className={cn(pending ? "bg-red-500 text-white" : "")}
        >
          Submit
        </button>
      </form>

actions.tsの引数:

// X
export const createMemo = async (formData: FormData): Promise<any> => {
}
  • useActionStateで上記のように書くとTypeError: formData.get is not a functionになる。

export const createMemo = async (
  _prev: any,
  formData: FormData
): Promise<any> => {
  console.log(prev, formData);
  • 引数を2つ受け取るようにすると2つ目にformDataが入る。

useFormStatus()

useFormStatus()はaction属性を使ったserverAction専用なので、onSubmitを使ったserverActionではpendingを追跡できない。

                <form
                  action={async (formData) => {
                    const result = await deleteAction(formData);

                    if (result?.success) {
                      toast.success(result.message, {
                        style: { background: "red", color: "white" },
                      });
                    } else {
                      toast.error(result.message, {
                        style: { background: "red", color: "white" },
                      });
                    }
                  }}
                >
                  <input type="hidden" name="id" value={id} />
                  <DeleteButton />
                </form>

DeleteButton.tsx :

export function DeleteButton() {
  const { pending } = useFormStatus();

  return (
    <Button
      type="submit"
      variant="destructive"
      size="sm"
      disabled={pending}
      className="p-2"
    >
      {pending ? <Loader2 className="animate-spin" /> : "X"}
    </Button>
  );
}
  • 上記のように切り分けるとフォーム毎( = アイテム毎)のpendingを把握できるので、ボタン単位でスピナーを回せる。

*useTransition()

useTrantisition()はset関数を呼び出した結果に発生する再レンダリングが重い場合、その処理をstartTransition()でラップすることにより再レンダリングを低優先度にする。

  • useTrantisition()もpending状態を取得できるが、formの場合は useFormStatusを使えばいいので、onClick()を使ったserverActionでpendingが欲しい場合に使う。

const [list, setList] = useState([]);
const [isPending, startTransition] = useTransition();

const action = async (formData) => {
  const data = await serverAction(formData);

  startTransition(() => {
    setList(filterData(data)); // 低優先度にしたい重い処理
  });
};

return (
  <form action={action}>
    <SubmitButton />
  </form>
);

useOptimistic

useOptimisticはactionが瞬時に成功したとみなしUI側をすぐに更新するので、時間がかかるようなserverActionと組み合わせて使える。

"use client";
import { useState } from "react";
import { testAction } from "./test-action";

const Page = () => {
  const [count, setCount] = useState<number>(0);

  const handle = async () => {
    const newCount = await testAction(count);
    setCount(newCount);
  };
  return (
    <div className="text-xl">
      <button onClick={() => setCount((prev) => prev + 1)}>Increment</button>
      count:{count}
      <button onClick={handle}>Test Action</button>
    </div>
  );
};

export default Page;

---

// action.ts
"use server";

export async function testAction(value: number) {
  await new Promise((r) => setTimeout(r, 2000));
  return value + 1;
}
  • 上記は2秒かかるserverAction

"use client";
import { useOptimistic, useState, useTransition } from "react";
import { testAction } from "./test-action";

const Page = () => {
  const [count, setCount] = useState(0);

  const [optimisticCount, setOptimisticCount] = useOptimistic(
    count,
    (state, value: number) => state + value,
  );

  console.log(count, optimisticCount);

  const [isPending, startTransition] = useTransition();

  const handle = () => {
    startTransition(async () => {
      setOptimisticCount(1);

      const newValue = await testAction(count);
      setCount(newValue);
    });
  };

  return (
    <div className="text-xl">
      <p>count: {optimisticCount}</p>
      <button onClick={handle}>
        {isPending ? "loading..." : "Test Action"}
      </button>
    </div>
  );
};

export default Page;
  • startTransition()で setOptimistic() を使っている処理をラップしないとブラウザで警告が出る。

  const [optimisticCount, setOptimisticCount] = useOptimistic(
    count,
    (state, value: number) => state + value,
  );
  • useOptimisticの第一引数は真実の値なので、今回はcountを参照している。第二引数はsetOptimisticを呼び出したときの更新ロジック

  • 上記の countとoptimisticCountをconsoleで見るとoptimisticCountの方はactionの結果を待たずに+1され、 countの方は2秒後に+1される。

  • serverAction側でerrorを返すような場合はtry/catchでcatchするとロールバックが起こる。今回の例だとcountの初期値である0に戻る。

  const handle = () => {
    startTransition(async () => {
      setOptimisticCount(1);

      try {
        const newValue = await testAction(count);
        setCount(newValue);
      } catch (error) {}
    });
  };

nextjs
/
tanstack
  • 勘違いしていた点
  • 定義
  • onClick
  • Nextjs
  • Tanstack-Start
  • ローディングUI
  • useActionState()
  • useFormStatus()
  • *useTransition()
  • useOptimistic