Next.js 14/15で「状態管理」を捨てろ:Server Actionsがもたらすアーキテクチャの革命

Next.js 14/15で「状態管理」を捨てろ:Server Actionsがもたらすアーキテクチャの革命

「またReact Queryを入れるのか?」

プロジェクトのセットアップ時、脳死で`npm install @tanstack/react-query`を叩くその指を一度止めてほしい。かつてSPA全盛期に我々が必死に構築した「クライアントサイドでのデータ同期」という複雑怪奇な城は、Next.jsのApp Routerという新しい地平において、もはや負債でしかない。

クライアントで状態を管理し、キャッシュをinvalidateし、ローディング状態を追いかける。そんな苦行から解放される時が来た。本稿では、Server ActionsとRSC(React Server Components)を軸にした、現代的なデータフローの設計思想を共有する。


1. パラダイムシフト:Client-side FetchingからServer-side Mutationへ

従来のReact開発では、「データ取得はクライアントで行うもの」という前提があった。しかし、Next.jsのApp Routerは「データはサーバーで取得し、アクションはサーバーで完結させる」という原則に回帰している。

なぜReact Queryが不要になるのか。それは、クライアントの状態管理が担っていた「サーバーとの同期」という責務を、Next.jsのData CacheとRouter Cacheがネイティブで肩代わりしてくれるからだ。

キャッシュの階層構造を理解する

多くのエンジニアが躓くのは、Next.jsが持つ二つのキャッシュ層の混同だ。

  • Data Cache: サーバーサイドの`fetch`結果を永続化する。`revalidatePath`や`revalidateTag`でパージされる。
  • Router Cache: ブラウザ側のメモリ上でセグメントごとのRSCペイロードを保持する。
`revalidatePath`を呼ぶと、サーバー側のData Cacheが更新され、次のナビゲーションや遷移時に最新のRSCペイロードがクライアントへ再送される。つまり、「手動でキャッシュを更新する」という概念自体が、サーバーサイドの再検証フローに置き換わるのだ。

2. 失敗から学ぶ:`revalidatePath`の罠とメカニズム

かつて私も「削除処理を叩いたのにUIが変わらない」という事態に半日を溶かしたことがある。原因は単純、パス指定の粒度だ。

`revalidatePath('/dashboard')` と書いたとき、Next.jsは `/dashboard` セグメントを再検証する。しかし、もし対象が `/dashboard/items/[id]` のような動的ルート内にあり、キャッシュタグの設計が甘ければ、親のキャッシュはパージされても、末端のセグメントが古いまま残ることがある。

教訓: パス指定に迷ったら `revalidateTag` を使え。

// actions/delete-item.ts
'use server'

import { revalidateTag } from 'next/cache'

export async function deleteItem(id: string) {
  await db.item.delete({ where: { id } })
  // パスに依存せず、タグ単位でキャッシュを無効化する
  revalidateTag('items-list')
}


3. 実装:Zodによる堅牢なServer Actions

Server Actionsはただの関数だ。だからこそ、境界線(Boundary)でのバリデーションが命綱になる。`zod`を組み合わせ、型安全なインターフェースを構築するユーティリティを作成しよう。

// lib/safe-action.ts
import { z } from 'zod';

export async function createSafeAction<T extends z.ZodSchema, R>(
  schema: T,
  action: (data: z.infer<T>) => Promise<R>
) {
  return async (formData: FormData | z.infer<T>) => {
    const data = formData instanceof FormData ? Object.fromEntries(formData) : formData;
    const result = schema.safeParse(data);
    
    if (!result.success) throw new Error('Invalid input');
    return action(result.data);
  };
}

これを使えば、サーバー側で予期せぬデータに爆死することはない。


4. リアルタイム性の正解:`useOptimistic`によるUXの最適化

「React Queryがないと楽観的更新(Optimistic UI)ができないのでは?」という懸念はもっともだ。しかし、React 18以降の `useOptimistic` を使えば、ライブラリなしで驚くほど滑らかに実装できる。

'use client'

import { useOptimistic, useTransition } from 'react'

export function ItemList({ initialItems }) {
  const [items, addOptimisticItem] = useOptimistic(initialItems, (state, newItem) => [...state, newItem])
  const [isPending, startTransition] = useTransition()

  return (
    <ul>
      {items.map(item => <li key={item.id}>{item.title}</li>)}
      <button onClick={() => startTransition(async () => {
        addOptimisticItem({ id: 'temp', title: '追加中...' })
        await addItemAction(...)
      })}>追加</button>
    </ul>
  )
}

React Queryが提供していた複雑な状態管理ではなく、「遷移中の状態」をUIの副次的な表現として扱う。これがApp Router流の最適解だ。


結論:持続可能なアーキテクチャに向けて

Server ActionsとRSCを使いこなすことは、単なる「技術トレンドの追従」ではない。クライアントとサーバーの境界を再定義し、「ネットワーク越しに状態を同期する」という最もコストのかかる処理を、フレームワークのキャッシュ機構にオフロードするという高度なエンジニアリングだ。

今後、Partial Prerendering (PPR) が標準化されれば、静的なUIと動的なデータ取得の境界はさらに曖昧になる。その時、クライアントで必死にキャッシュを管理するコードは、完全に時代遅れの遺物となるだろう。

「何でもかんでもライブラリで解決する」という思考を捨てよう。サーバーに任せられることはサーバーにやらせる。その潔さこそが、半年後の君を救う、最もメンテナンス性の高いコードベースへの近道だ。