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に使えるフック:
useActionStatependingが分かる。
結果も持てる。
formタグ以外で使える。
useFormStatuspendingが分かる。
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) {}
});
};