Server Actionsを「データ取得」に使うな:Next.jsの流儀を再定義する

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を`fetch`の代わりにするな。それは「データ取得」という本来の目的に対して、あまりにもオーバーヘッドが大きく、キャッシュ戦略も貧弱すぎる。

なぜ「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のキャッシュ: クライアントのインタラクションに対する即時性を担保する。
整合性を保つには、「楽観的更新(Optimistic Update)」が必須だ。ユーザーがボタンを押した瞬間、サーバーからのレスポンスを待たずにUIを書き換える。これができないと、どんなに高速なサーバーでも「遅い」と感じさせてしまう。

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が書いたような綺麗な解説記事はもういい。現場で泥をすすり、バグと格闘した者だけが知るこの「規律」こそが、プロダクトを死なせない唯一の道だ。