「Server ActionsさえあればTanStack Queryは不要」という幻想を捨てろ

「Server ActionsさえあればTanStack Queryは不要」という幻想を捨てろ

「全部Server Actionsでいいじゃん」。Next.js 14が出た頃の私は、本気でそう思っていた。APIルートを書く手間も、クライアントとサーバー間の型定義のズレも、すべてが解決したかのような万能感。

だが、現実は甘くなかった。複雑なダッシュボードを組み上げ、本番環境でユーザーが触り始めた瞬間、その「万能感」は崩れ去った。画面遷移のたびに走る再フェッチでUIはガタつき、キャッシュの整合性が取れずに古いデータが居座り続ける。

結局、現場で求められるのは「どちらか一方」ではなく、両者の特性を理解した上での「泥臭い使い分け」だった。


1. Server Actionsは「状態管理」の道具ではない

多くのエンジニアが犯す最大の勘違いは、Server ActionsをAPIの代替品として、あるいは「アプリの全状態を制御する魔法の杖」として見ることだ。

Server Actionsはあくまで「サーバーサイドの関数を呼び出すためのインターフェース」に過ぎない。これを状態管理ツールとして使い、更新のたびに `revalidatePath` でページ全体を再レンダリングさせるのは、UXを損なう無駄の極みだ。

型安全な連携のベストプラクティス

TanStack Queryの `queryFn` としてServer Actionsを呼び出す際、型定義で躓く人は多い。以下のように、Server Actionsの戻り値を型として抽出し、クエリ側で再利用するのが正解だ。

// actions/profile.ts
export async function getProfile() {
  const user = await db.user.findFirst();
  return user;
}

export type ProfileData = Awaited<ReturnType<typeof getProfile>>;

// hooks/useProfile.ts
export const useProfile = () => {
  return useQuery<ProfileData>({
    queryKey: ['profile'],
    queryFn: getProfile, // 直接呼び出しても型は安全に伝播する
  });
};


2. 楽観的UIの裏側にある「巻き戻り」の恐怖

TanStack Queryの `useMutation` を使った楽観的UIは、UXを爆上げする最強の武器だ。しかし、ここには深い落とし穴がある。

かつて私が実装したプロフィール更新機能では、Server Actionsの応答とキャッシュの更新タイミングが競合し、データが更新直後に元の値へ「巻き戻る」というバグに3時間悩まされた。原因は `onSettled` での無効化処理と、楽観的更新の順序が不適切だったことだ。

競合を防ぐための実装パターン

`revalidatePath` を併用する場合、TanStack Query側でキャッシュを無効化するタイミングを慎重に制御しなければならない。

const { mutate } = useMutation({
  mutationFn: updateProfile,
  onMutate: async (newData) => {
    await queryClient.cancelQueries({ queryKey: ['profile'] });
    const previous = queryClient.getQueryData(['profile']);
    
    // 楽観的更新
    queryClient.setQueryData(['profile'], newData);
    return { previous };
  },
  onError: (_, __, context) => {
    queryClient.setQueryData(['profile'], context?.previous);
  },
  onSettled: () => {
    // サーバーの最新状態と同期させる
    queryClient.invalidateQueries({ queryKey: ['profile'] });
  },
});

ここで重要なのは、「楽観的更新」と「サーバーの最新状態の再取得」の間に明確な境界線を作ることだ。これを怠ると、不安定なネットワーク環境下でUIがチカチカと点滅し、ユーザーの信頼を一瞬で失うことになる。


3. 失敗談:キャッシュの深淵と「再検証」の代償

「キャッシュを常に最新に保ちたい」という衝動は、エンジニアの性だ。かつて私は、サイドバーのステータス表示を更新するために、コンポーネントの末端で `revalidateTag` を乱発したことがある。

結果はどうなったか? Vercelのデータキャッシュクォータを数時間で使い切り、API制限に引っかかってサイトがダウンした。

教訓:どこで「検証」するか

  • 悪い例: ユーザーの操作に関連しない深いネストのコンポーネントで `revalidatePath` を呼ぶ。
  • 良い例: データの変更(Mutation)が発生する「出口」でのみ `revalidate` を行い、クライアント側ではTanStack Queryの `staleTime` を適切に設定して、無駄なフェッチを抑制する。
サーバーに過度な負荷をかける前に、クライアントサイドで賢くキャッシュを扱う。「サーバーに全投げ」は、スケーラビリティの観点からは最も避けるべき敗北だ。

結論:使い分けのルール

私が辿り着いた、今のプロジェクトでの運用ルールはシンプルだ。

機能役割ツール
データ取得初期表示、SEOが必要なものServer Actions (RSC)
データ更新サーバー側での副作用Server Actions
キャッシュ管理クライアント側の状態、再試行TanStack Query
楽観的UI即時フィードバックTanStack Query

Next.jsがすべてを解決してくれるという幻想を捨てろ。Server Actionsはサーバーとの「会話」に使い、TanStack Queryはクライアント側の「記憶」に使え。

この二つをどう調和させるか。その泥臭い試行錯誤の中にこそ、真に快適なアプリケーションの正解がある。