RPC:server-actions / functions
2026年7月19日RPC
Next.jsのServer ActionsやTanStack StartのcreateServerFnは、クライアントとサーバー間の通信を「RPC(Remote Procedure Call)」として抽象化(サーバーとクライアントの境界をあまり意識しなくて済む)する仕組み。Nextjsだと "use server"を書いたファイル、Tanstack-startだとcreateServerFn() が該当する。
例えばNextjsでPrismaをクライアントコンポーネントで使いたい場合にuseEffect()内のfetchからAPIルートを経由するような形で行うこともできるが、server Actionsを使えばAPIルートを経由せずにbuttonやformからPrismaを使った処理を実行できる。Tanstack-startも同様にcreateServerFn()から実行できる。
仕組み:
"use server"やcreateServerFn()という文字があるとフレームワーク側がビルド時にそのファイルや関数をクライアントバンドルに含めない為、サーバー側にロジックが閉じられ安全にクライアント側からサーバーサイドで処理される関数を呼べる。RPCが行う内部的なHTTPリクエストのエンドポイントは検証ツールからわかるため、認証やバリデーションはデフォルトで行う必要がある。
Nextjs
ローディング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) {}
});
};Tanstack-start
Nextjsのサーバーアクションで使う上記のuseFormStatusやuseActionStateはReact標準なのでTanstack-startでも使うことができるが、Tanstack-startではtanstack-queryのuseMutation()を使ってサーバー関数の状態を追跡するのがベストプラクティスとされる。
function TodoItem({ id, completed }: { id: string, completed: boolean }) {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: updateTodoStatus,
onSuccess: () => {
// 成功したら一覧のキャッシュを無効化して再取得
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
return (
<div>
<button
onClick={() => mutation.mutate({ id, completed: !completed })}
disabled={mutation.isPending} // 送信中の状態を自動で追跡
>
{mutation.isPending ? '更新中...' : 'ステータス切替'}
</button>
{mutation.isError && <p>エラーが発生しました</p>}
</div>
)
}import { createServerFn } from '@tanstack/start'
import { z } from 'zod'
export const updateTodoStatus = createServerFn({ method: 'POST' })
.validator(z.object({ id: z.string(), completed: z.boolean() }))
.handler(async ({ data }) => {
// DB操作
return { success: true, id: data.id }
})