Server Actionsを「データ取得」に使うな:Next.jsの流儀を再定義する
Next.jsのApp Routerが登場して久しいが、現場のコードベースを見るたびに吐き気がする。「Server Actionsでデータを取得して、`useState`に突っ込んで管理する」という、まるでReactの黎明期に逆戻りしたような実装が蔓延しているからだ。
「Server ActionsがあればTanStack Queryはいらない」? 悪いことは言わない、その幻想は今すぐ捨てろ。そんな実装を続けていれば、数ヶ月後には`useEffect`だらけのスパゲッティコードに足を取られ、運用保守という名の地獄を味わうことになる。
これは、僕が数々の現場で「なぜその設計にしたんだ」と頭を抱え、泥臭い試行錯誤の末に辿り着いた、Next.jsにおける「正解の棲み分け」の話だ。
1. 境界線を引け:役割の誤解がバグを呼ぶ
この議論がこじれる最大の原因は、Server ActionsとTanStack Queryの役割を混同していることにある。
- Server Actions: あくまで「副作用(Mutation)」。DB更新、認証、決済といった「状態変化」をトリガーするものだ。
- TanStack Query: 「クライアントサイドのキャッシュ管理」。サーバーから取得した状態を、UIと同期させるための高度な同期エンジンだ。
なぜ「Server Actionsで取得」が地獄への入り口なのか
Server ActionsはPOSTリクエストを強制する。GETのキャッシュを活用できない上に、クライアント側でローディング状態やエラーハンドリングを自前で実装する必要がある。`isLoading`, `isError`, `isFetching`... これらを全部`useState`で管理するつもりか? それはReact Queryが解決済みの課題を、わざわざ車輪の再発明をしているだけに過ぎない。2. 実装の正解:Server ActionsとQueryの「美しい共存」
最も保守性が高く、かつ堅牢な構成は、「データ取得はServer Actionsを再利用する」という設計だ。APIルートを別途作る必要はない。Server Actionsを単なる「サーバーサイドの関数」として扱い、それをQueryの`queryFn`から呼び出すのが、今のNext.jsの正解だ。
// actions.ts (サーバーサイドのロジックを集中させる)
'use server'
export async function getUserProfile() {
// DBアクセスや外部API呼び出し
return await db.user.findUnique({ where: { id: 1 } });
}
export async function updateProfile(data: FormData) {
// 更新ロジック
await db.user.update({ ... });
revalidateTag('user-profile'); // Next.jsのData Cacheを無効化
}
// ProfileComponent.tsx (クライアントサイド)
'use client'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { getUserProfile, updateProfile } from './actions'
export default function Profile() {
const queryClient = useQueryClient()
// 取得はQueryに任せる。Server Actionを直接呼ぶことで型安全も担保される
const { data } = useQuery({
queryKey: ['user'],
queryFn: getUserProfile,
})
const mutation = useMutation({
mutationFn: updateProfile,
onSuccess: () => {
// 成功後にQueryのキャッシュを無効化し、再取得をトリガー
queryClient.invalidateQueries({ queryKey: ['user'] })
}
})
return <form action={mutation.mutate}>...</form>
}
この構成の強みは、「取得ロジックがサーバーサイドに集約されている」点にある。フロントエンドは「何を取得するか」を知っているだけで、その取得経路(DBなのか外部APIなのか)を意識する必要がない。
3. キャッシュの競合:UX上の優先順位を制御せよ
最大のハマりポイントは、「Next.jsのData Cache」と「TanStack Queryのキャッシュ」の二重管理だ。
- Next.jsのキャッシュ: サーバーサイドでのレンダリング効率を最大化する。
- TanStack Queryのキャッシュ: クライアントのインタラクションに対する即時性を担保する。
const mutation = useMutation({
mutationFn: updateProfile,
onMutate: async (newData) => {
await queryClient.cancelQueries({ queryKey: ['user'] });
const previous = queryClient.getQueryData(['user']);
// UIを即座に更新
queryClient.setQueryData(['user'], newData);
return { previous };
},
onError: (err, _, context) => {
// 失敗したら元に戻す
queryClient.setQueryData(['user'], context?.previous);
}
});
4. 現場で泣かないための「落とし穴」
最後に、僕が3時間溶かした「シリアライズの罠」をもう一つ共有しておく。
罠:Dateオブジェクトとクラスインスタンス
Server Actionsは内部でJSONシリアライズを行う。つまり、`Date`オブジェクトや`Map`、`Set`、あるいは自作のクラスインスタンスをそのまま返すと、クライアント側では「ただの空のオブジェクト」や`undefined`になって届く。- 対策: 戻り値は必ず`plain object`(POJO)に変換すること。`superjson`のようなライブラリを使う手もあるが、まずは「プリミティブなデータしか返さない」という規律を持つことが、トラブルを未然に防ぐ一番の防御策だ。
罠:`revalidateTag`の「ラグ」
`revalidateTag`を呼んでも、即座にキャッシュが消えるわけではない。サーバー側のキャッシュ更新とクライアント側のQueryの再取得には、ネットワーク分だけのラグが生じる。これを見越して、UI側で「保存中...」というステータス表示を徹底すること。エンジニアの自己満足ではなく、ユーザーの安心感のために不可欠な実装だ。結論:規律なき自由はスパゲッティを生む
Next.jsは強力だが、その自由度ゆえに「何でもできる」が「何でもやっていい」わけではない。
1. 取得はTanStack Query + Server Actionsの関数呼び出し
2. 更新はServer Actions + `invalidateQueries`
3. 整合性は楽観的更新で担保する
この型を守るだけで、君のコードは驚くほどクリーンになり、数ヶ月後の自分に感謝されるはずだ。AIが書いたような綺麗な解説記事はもういい。現場で泥をすすり、バグと格闘した者だけが知るこの「規律」こそが、プロダクトを死なせない唯一の道だ。