4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

備忘録:Next.jsのServer ActionsをRSC・React 19から理解する。SSR・Hydration・再描画との関係を1本の流れに整理する

4
Last updated at Posted at 2026-08-22

はじめに

Server Actionsは、「APIを作らずにサーバー関数を呼べる機能」と説明されることがあります。
自分もそのくらいの粒度で使っていました。
この説明でも動くものは作れますが、'use client' をどこに置くか、更新後に画面がなぜ変わるのか、といったところで毎回手が止まります。

Next.js App Routerでは、画面が主に次の役割分担で構成されます。

React Server Components
  サーバー状態を読み取り、UIを生成する

Client Components
  ユーザー操作とブラウザ上の状態を扱う

Server Actions
  ユーザー操作をサーバー状態へ反映する

RSCの再実行
  更新後のサーバー状態からUIを生成し直す

この配置で見ると、Server Actionsは単独の便利機能ではありません。
RSCで構築したサーバー主導のUIに、更新経路を1本足す仕組みです。

そこで、SSR、RSC、Client Components、Server Functions、React 19のAction系Hooksを、1つの処理フローとして整理し直しました。
その記録が本記事です。

環境は React 19 / Next.js App Router(React 19 に対応した Next.js 15 以降)/ TypeScript を前提にしています。
キャッシュ挙動や Progressive Enhancement の範囲はバージョンによって差があるので、最終的な確認はそのつど利用中のバージョンの公式ドキュメントで行います。
掲載するコードは仕組みの説明を目的とした最小例で、実務ではエラー処理や監査ログを別途足す必要があります。
参照した公式ドキュメントは記事末に列挙しました。

1. 最初に4つの概念を分離する

RSC周辺が分かりにくい理由は、異なるレイヤーの概念が同時に説明されるためです。

概念 答える問い
MPA / SPA ページ遷移をどのように行うか
SSR / CSR 初期HTMLをどこで生成するか
Server / Client Components Reactコンポーネントをどこで実行するか
Server Actions / Route Handlers ブラウザからサーバー処理をどう呼び出すか

これらは単純な置き換え関係ではありません。

Next.js App Routerの1ページは、同時に次の性質を持てます。

初回アクセス
  → サーバーでHTMLを生成して早く表示する

コンポーネント構成
  → Server ComponentとClient Componentを組み合わせる

ページ内遷移
  → SPAのようにフルリロードせず移動する

データ更新
  → Server Actionを呼び出す

更新後
  → 新しいRSC Payloadを既存のReactツリーへ反映する

2. SSRとRSCは何が違うのか

SSRの目的

SSRは、初回表示用HTMLをサーバーで生成する仕組みです。

Reactコンポーネント
  ↓ サーバーでレンダリング
HTML
  ↓ ブラウザへ送信
画面表示
  ↓ JavaScript読み込み
Hydration

従来型のSSRでは、サーバーでHTMLを生成しても、ブラウザ上で同じReactコンポーネントを動作させるため、対応するJavaScriptが必要になります。

RSCの目的

React Server Componentsは、コンポーネント自体をサーバーで実行し、そのコンポーネントのJavaScriptをブラウザへ送らない仕組みです。

// Server Component
import { db } from '@/lib/db'

export default async function ProductList() {
  const products = await db.product.findMany()

  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  )
}

このコンポーネントについて、ブラウザへ送る必要がないものは次のとおりです。

  • DBアクセスコード
  • DBドライバー
  • サーバー専用ライブラリ
  • ProductList関数の実装
  • サーバー側だけで使用する依存パッケージ

ブラウザには、サーバーで実行した結果と、Client Componentとの接続情報が送られます。

違いを一文で表す

SSR
  → HTMLをどこで生成するか

RSC
  → コンポーネントをどこで実行し、どのJavaScriptを配信しないか

SSRとRSCは競合するものではなく、Next.jsでは初回表示時に組み合わせて使われます。

3. RSC Payloadとは何か

Server Componentは、直接ブラウザ用JavaScriptになるわけではありません。

Next.jsはServer Componentの実行結果を、RSC Payloadと呼ばれるReact用のデータ表現へ変換します。

概念的には次の情報が含まれます。

Server Componentのレンダリング結果
Client Componentを挿入する場所
Client Componentのモジュール参照
Server Componentから渡すProps
Suspense境界
Reactツリーを再構成するための情報

初回アクセス時の大まかな流れは次のとおりです。

初回アクセスの8ステップ。1ブラウザがページを要求、2 Server Componentsをサーバーで実行、3 RSC Payloadを生成、4 RSC PayloadとClient ComponentsからHTMLを生成、5 HTMLをブラウザへ送信、6ブラウザがHTMLを表示、7 Client Component用JavaScriptを読み込む、8 Client ComponentsをHydrationして操作可能にする

テキスト版を開く
1. ブラウザがページを要求する
2. Server Components をサーバーで実行する
3. RSC Payload を生成する
4. RSC Payload と Client Components から HTML を生成する
5. HTML をブラウザへ送信する
6. ブラウザが HTML を表示する
7. Client Component 用 JavaScript を読み込む
8. Client Components を Hydration して操作可能にする

4. Client Componentはブラウザだけで描画されるわけではない

次のコンポーネントはClient Componentです。

'use client'

import { useState } from 'react'

export function Counter() {
  const [count, setCount] = useState(0)

  return (
    <button onClick={() => setCount((value) => value + 1)}>
      {count}
    </button>
  )
}

Client Componentである理由は、ブラウザ上で次を扱うためです。

  • useState
  • onClick
  • ユーザー操作後も維持する状態

ただし、初回表示ではClient Componentもサーバー側でHTMLへ事前レンダリングされる場合があります。

その後、ブラウザにJavaScriptが届き、Hydrationされることで操作可能になります。

Client Component
  初回HTML生成: サーバーでも処理され得る
  イベント処理: ブラウザで実行
  状態管理: ブラウザで実行
  JavaScript: ブラウザへ送られる
  Hydration: 必要

Server Componentは次のとおりです。

Server Component
  コンポーネント実行: サーバーのみ
  JavaScript: ブラウザへ送られない
  Hydration: 不要
  useState / onClick: 使用できない

5. 'use client'は依存ツリーの境界を作る

'use client'は、関数単体に「ブラウザで実行する」という印を付けるだけではありません。

そのファイルからimportされる依存関係も、クライアントバンドルへ含まれる可能性があります。

ClientComponent.tsx
├── Button.tsx
├── formatter.ts
└── それらがimportする依存関係

そのため、ページ全体へ安易に'use client'を付けると、表示専用コードや大きなライブラリまでブラウザへ送られます。

推奨する分割

// Server Component
import { FavoriteButton } from './FavoriteButton'
import { getProduct } from '@/features/products/query'

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const product = await getProduct(id)

  return (
    <article>
      <h1>{product.name}</h1>
      <p>{product.description}</p>

      {/* 操作が必要な末端だけClient Component */}
      <FavoriteButton productId={product.id} />
    </article>
  )
}
// FavoriteButton.tsx
'use client'

import { useState } from 'react'

export function FavoriteButton({
  productId,
}: {
  productId: string
}) {
  const [selected, setSelected] = useState(false)

  return (
    <button
      type="button"
      onClick={() => setSelected((value) => !value)}
      aria-pressed={selected}
    >
      {selected ? 'お気に入り済み' : 'お気に入り'}
    </button>
  )
}

基本方針は次のとおりです。

可能な限りServer Componentにし、ブラウザ状態やイベントが必要な末端だけClient Componentにする。

6. Server ComponentからClient Componentへ渡せる値

Server ComponentとClient Componentの間にはネットワーク境界があります。

そのため、PropsはReactがシリアライズできる値である必要があります。

React 公式が挙げているのは次の値です。

  • string / number / bigint / boolean / undefined / null
  • Symbol.for() でグローバル登録された Symbol
  • シリアライズ可能な値を含む Iterable(String / Array / Map / Set / TypedArray / ArrayBuffer)
  • Date
  • シリアライズ可能なプロパティを持つプレーンオブジェクト
  • Server Function
  • Client / Server Component の React 要素(JSX)
  • Promise

FormData は Server Function の引数としては使えますが、Client Component へ渡す Props の一覧には入っていません。
引数のシリアライズ規則と Props のシリアライズ規則は別ものです。

通常は渡せないものは次のとおりです。

  • 通常の関数
  • DB接続オブジェクト
  • DOMノード
  • シリアライズできないクラスインスタンス
  • サーバー専用ライブラリのインスタンス

次のようなPropsは渡せません。

// Server Component
<ClientComponent
  onSave={() => {
    console.log('save')
  }}
/>

通常の関数をClient Componentへ渡したい場合は、Client Component側で定義します。

サーバー処理を渡したい場合は、'use server'でServer Functionとして公開します。

7. Server FunctionとServer Action

Reactの現在の用語では、'use server'で公開された非同期関数をServer Functionと呼びます。

// app/reservations/actions.ts
'use server'

export async function createReservation(formData: FormData) {
  // サーバーで実行
}

Client Componentからimportすると、関数本体がブラウザへ送られるわけではありません。

ビルド時にServer Functionへの参照へ変換されます。

Client Component
  ↓ Server Function参照を呼ぶ
React / Next.jsがリクエストを生成
  ↓
サーバーで関数を実行
  ↓
シリアライズ可能な戻り値を返す

コード上は関数呼び出しですが、実体はネットワーク通信です。

await createReservation(formData)

したがって、Server Functionは次のように扱う必要があります。

外部入力を受け取るエンドポイント

8. RSCとServer Actionsがセットになりやすい理由

RSCだけでは、主にサーバーからブラウザへの読み取り方向を担当します。
Webアプリには逆方向も必要で、そこを埋めるのが Server Actions です。

上段が読み取り方向で、DatabaseからServer Componentが状態を読み取り、RSC PayloadとしてBrowserへ渡る。下段が更新方向で、Client Componentのユーザー操作がServer Actionへ渡り、Databaseを更新する。更新後は再検証してServer Componentを再実行し、上段へ戻る

テキスト版を開く
読み取り方向
Database → Server Component → RSC Payload → Browser

更新方向
Browser(ユーザー操作) → Server Action → Database 更新
  → 再検証して Server Component を再実行
  → 新しい RSC Payload → Browser へ反映

この循環が、RSCとServer Actionsを組み合わせる基本モデルです。

9. Server Action実行時に何が起きるか

次のフォームを考えます。

<form action={createReservationAction}>
  <input name="customerName" />
  <input name="date" type="date" />
  <button type="submit">予約する</button>
</form>

送信時には、概念的に次の処理が行われます。
ここで注意したいのは、6〜8がNext.jsの自動処理ではないことです。
認証・認可・DB更新・再検証は、いずれもAction内に自分で書く処理です。
9以降も、表示中のパスを再検証した場合に起きるもので、再検証しなければ画面は変わりません。

Server Action実行時の12ステップ。1フォーム送信、2ブラウザからNext.jsへリクエスト、3 Server Functionを特定、4 FormDataや引数を復元、5 Server Actionを実行、6認証・認可・入力検証をAction内に自分で書く、7データベースを更新、8必要ならキャッシュを再検証、9表示中のパスを再検証したならServer Componentsを再実行、10新しいRSC Payloadを生成、11 Actionの結果とあればRSC Payloadを返す、12 Reactが既存ツリーへ反映する

テキスト版を開く
1. ユーザーがフォームを送信する
2. ブラウザから Next.js へリクエストが飛ぶ
3. 対象の Server Function を特定する
4. FormData や引数を復元する
5. Server Action を実行する
6. 認証・認可・入力検証を行う(Action 内に自分で書く)
7. データベースを更新する(Action 内に自分で書く)
8. 必要ならキャッシュを再検証する(Action 内に自分で書く)
9. 表示中のパスを再検証したなら Server Components を再実行する
10. 新しい RSC Payload を生成する
11. Action の結果と、あれば RSC Payload を返す
12. React が既存ツリーへ反映する

ここが通常のREST APIとの大きな違いです。

APIはJSONを返すことが主目的ですが、Server Actionは更新後のReactツリーを再構成する処理と統合できます。
「必ず統合される」ではなく「同じ往復で返せる」という能力の話です。

10. RSC + Server Actionの実装例

予約一覧と予約登録フォームを実装します。

ディレクトリ構成

app/
└── reservations/
    ├── page.tsx
    ├── ReservationForm.tsx
    └── actions.ts

features/
└── reservations/
    ├── command.ts
    └── query.ts

表示用Query

// features/reservations/query.ts
import { db } from '@/lib/db'

export async function listReservations() {
  return db.reservation.findMany({
    orderBy: {
      date: 'asc',
    },
  })
}

更新用Service

// features/reservations/command.ts
import { db } from '@/lib/db'

type ReserveInput = {
  operatorId: string
  customerName: string
  date: Date
}

export async function reserve(input: ReserveInput) {
  const duplicated = await db.reservation.findFirst({
    where: {
      customerName: input.customerName,
      date: input.date,
    },
  })

  if (duplicated) {
    return {
      ok: false as const,
      reason: 'DUPLICATED' as const,
    }
  }

  const reservation = await db.reservation.create({
    data: {
      customerName: input.customerName,
      date: input.date,
      createdBy: input.operatorId,
    },
  })

  return {
    ok: true as const,
    reservation,
  }
}

Server Action

// app/reservations/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { requireSession } from '@/lib/auth'
import { reserve } from '@/features/reservations/command'

export type ReservationActionState = {
  success: boolean
  message: string
  errors?: {
    customerName?: string[]
    date?: string[]
  }
}

export async function createReservationAction(
  _previousState: ReservationActionState,
  formData: FormData,
): Promise<ReservationActionState> {
  const session = await requireSession()

  const rawCustomerName = formData.get('customerName')
  const rawDate = formData.get('date')

  const errors: ReservationActionState['errors'] = {}

  if (
    typeof rawCustomerName !== 'string' ||
    rawCustomerName.trim() === ''
  ) {
    errors.customerName = ['氏名を入力してください']
  }

  if (typeof rawDate !== 'string' || rawDate === '') {
    errors.date = ['利用日を入力してください']
  }

  if (Object.keys(errors).length > 0) {
    return {
      success: false,
      message: '入力内容を確認してください',
      errors,
    }
  }

  const date = new Date(rawDate)

  if (Number.isNaN(date.getTime())) {
    return {
      success: false,
      message: '入力内容を確認してください',
      errors: {
        date: ['正しい日付を入力してください'],
      },
    }
  }

  const result = await reserve({
    operatorId: session.userId,
    customerName: rawCustomerName.trim(),
    date,
  })

  if (!result.ok) {
    return {
      success: false,
      message: '同じ内容の予約が登録されています',
    }
  }

  revalidatePath('/reservations')

  return {
    success: true,
    message: '予約を登録しました',
  }
}

Server Component

// app/reservations/page.tsx
import { listReservations } from '@/features/reservations/query'
import { ReservationForm } from './ReservationForm'

export default async function ReservationsPage() {
  const reservations = await listReservations()

  return (
    <main>
      <h1>予約一覧</h1>

      <ReservationForm />

      <ul>
        {reservations.map((reservation) => (
          <li key={reservation.id}>
            {reservation.customerName}
            {' / '}
            {reservation.date.toLocaleDateString('ja-JP')}
          </li>
        ))}
      </ul>
    </main>
  )
}

Client Component

// app/reservations/ReservationForm.tsx
'use client'

import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import {
  createReservationAction,
  type ReservationActionState,
} from './actions'

const initialState: ReservationActionState = {
  success: false,
  message: '',
}

function SubmitButton() {
  const { pending } = useFormStatus()

  return (
    <button type="submit" disabled={pending}>
      {pending ? '登録中...' : '予約する'}
    </button>
  )
}

export function ReservationForm() {
  const [state, formAction] = useActionState(
    createReservationAction,
    initialState,
  )

  return (
    <form action={formAction}>
      <div>
        <label htmlFor="customerName">氏名</label>
        <input id="customerName" name="customerName" />

        {state.errors?.customerName?.map((message) => (
          <p key={message}>{message}</p>
        ))}
      </div>

      <div>
        <label htmlFor="date">利用日</label>
        <input id="date" name="date" type="date" />

        {state.errors?.date?.map((message) => (
          <p key={message}>{message}</p>
        ))}
      </div>

      <SubmitButton />

      {state.message && (
        <p aria-live="polite">{state.message}</p>
      )}
    </form>
  )
}

11. このコードの初回表示

ReservationsPageをサーバーで実行
  ↓
listReservationsでDBから取得
  ↓
Server Component部分をRSC Payloadへ変換
  ↓
ReservationFormはClient Component参照として組み込む
  ↓
初期HTMLを生成
  ↓
ブラウザへ送信
  ↓
HTMLを表示
  ↓
ReservationFormのJavaScriptを読み込む
  ↓
Hydrationして操作可能になる

12. フォーム送信後の流れ

ReservationFormからActionを実行
  ↓
createReservationActionがサーバーで動作
  ↓
認証・入力検証
  ↓
reserveでDB更新
  ↓
revalidatePath('/reservations')
  ↓
ReservationsPageを再実行
  ↓
最新の予約一覧を取得
  ↓
新しいRSC Payloadを返す
  ↓
Reactが既存の画面へマージ

ブラウザ側で明示的に一覧APIを再取得して、配列へ追加するコードを書かなくても、Server Componentを最新状態から再生成できます。

13. useActionStateの役割

useActionStateは、一般的なuseStateの代わりではありません。

Actionに関係する次の状態を扱うためのHookです。

  • Actionの最後の戻り値
  • 送信中状態
  • 前回の状態を受け取る処理
  • Hydration前のフォーム応答
  • Progressive Enhancement

基本形は次のとおりです。

const [state, formAction, isPending] = useActionState(
  actionFunction,
  initialState,
)

Action関数のシグネチャは次のとおりです。

async function actionFunction(
  previousState: State,
  payload: FormData,
): Promise<State> {
  // ...
}

フォームと組み合わせた場合、2番目の引数は通常FormDataです。

初回
  state = initialState

Action実行
  previousState = 現在のstate

Action完了
  state = Actionの戻り値

useStateが引き続き必要な例

  • モーダルの開閉
  • 選択中のタブ
  • 入力途中のローカル値
  • ブラウザ内だけで完結する表示状態
  • ドラッグ中の状態

useActionStateは、Actionの実行結果をUIへ接続するための専用Hookです。

14. useFormStatusの役割

useFormStatusは、親の<form>が送信中かどうかを子コンポーネントから取得します。

function SubmitButton() {
  const { pending, data, method, action } = useFormStatus()

  return (
    <button type="submit" disabled={pending}>
      {pending ? '送信中...' : '送信'}
    </button>
  )
}

重要な制約があります。

useFormStatusを呼ぶコンポーネントは、対象フォームの内側に配置する必要があります。

次は誤った例です。

function Form() {
  // このコンポーネント自身が返すformは親ではない
  const { pending } = useFormStatus()

  return (
    <form action={submitAction}>
      <button disabled={pending}>送信</button>
    </form>
  )
}

次が正しい例です。

function SubmitButton() {
  const { pending } = useFormStatus()

  return <button disabled={pending}>送信</button>
}

function Form() {
  return (
    <form action={submitAction}>
      <SubmitButton />
    </form>
  )
}

15. useOptimisticの役割

Server Actionの完了を待ってから画面を変えると、ネットワーク往復分の遅延があります。

クリック
  ↓
Server Action
  ↓
DB更新
  ↓
レスポンス
  ↓
画面更新

useOptimisticを使うと、サーバー応答前に一時的な表示を作れます。

クリック
  ↓
画面を先に更新
  ↓
Server Action
  ↓
成功なら正式な状態へ収束
失敗なら元の状態へ戻す、またはエラー表示

次に例を示します。

'use client'

import { useOptimistic, startTransition } from 'react'
import { toggleFavoriteAction } from './actions'

type Props = {
  productId: string
  favorite: boolean
}

export function FavoriteButton({
  productId,
  favorite,
}: Props) {
  const [optimisticFavorite, setOptimisticFavorite] =
    useOptimistic(favorite)

  function handleClick() {
    startTransition(async () => {
      setOptimisticFavorite(!optimisticFavorite)
      await toggleFavoriteAction(productId)
    })
  }

  return (
    <button type="button" onClick={handleClick}>
      {optimisticFavorite ? 'お気に入り済み' : 'お気に入り'}
    </button>
  )
}

楽観的更新が適しているのは、失敗率が低く、操作の取り消しやエラー表示が可能な処理です。

次の処理では慎重に使います。

  • 決済確定
  • 在庫の最終確保
  • 法的な承認
  • 取り消し不能な処理
  • セキュリティ設定変更

16. Progressive Enhancement

<form action={serverFunction}> は、JavaScript の読み込み完了前でもフォーム送信を成立させやすい構造です。
ただし「成立する」と「キューに入る」は別の挙動で、フォームをどこに置くかで変わります。
ここを1つにまとめて書いていたのが自分の理解の粗さでした。

Server Component 内のフォーム
  → Server Action を <form action> へ直接渡した場合、
    JavaScript 未読込・無効時でも通常のフォーム送信として動作する

Client Component 内のフォーム
  → Hydration 前の送信はキューに入り、Hydration 後に実行される

startTransition() 経由のイベントハンドラ呼び出し
  → フォームの Progressive Enhancement は働かない

useActionState の permalink
  → JavaScript 読込前の送信時に指定 URL へ移動する。
    遷移先でも同じ Action と permalink を持つフォームを描画する必要がある

Progressive Enhancementを重視する場合は、実際の対象ブラウザとデプロイ環境で確認します。

17. Server Actionへ追加引数を渡す

フォームに含まれない値を渡す場合は、bind()を利用できます。

import { updateUserAction } from './actions'

export function UserForm({
  userId,
}: {
  userId: string
}) {
  const updateUser = updateUserAction.bind(null, userId)

  return (
    <form action={updateUser}>
      <input name="name" />
      <button type="submit">更新</button>
    </form>
  )
}

Action側の実装は次のとおりです。

'use server'

export async function updateUserAction(
  userId: string,
  formData: FormData,
) {
  // ...
}

ただし、userIdはクライアントから変更可能な入力値として扱います。

const session = await requireSession()
const user = await db.user.findUnique({ where: { id: userId } })

if (!user || user.tenantId !== session.tenantId) {
  throw new Error('Forbidden')
}

bind()で渡したから安全、hidden inputだから安全、ということはありません。

18. エラーの扱い

想定されるエラー

入力ミスや業務ルール違反は、ActionStateとして返します。

return {
  success: false,
  message: '指定期間は予約済みです',
}

想定外のエラー

プログラム不具合、DB障害、外部サービス障害などはthrowします。

throw new Error('Payment provider unavailable')

ReactはError BoundaryやNext.jsのerror.tsxへ処理を移せます。

基本方針は次のとおりです。

ユーザーが修正できるエラー
  → 戻り値

システム障害・想定外エラー
  → throw

ただし、機密情報をエラーメッセージへ含めないようにします。

19. キャッシュ再検証を理解する

DBを更新しただけでは、現在表示しているServer Componentが自動的に最新データを取得するとは限りません。

更新後に、キャッシュ戦略に対応した再検証を行います。

revalidatePath('/reservations')

考え方は次のとおりです。

DBの状態
  → 更新済み

Next.jsのキャッシュ
  → 古い可能性がある

現在のRSC表示
  → 再生成が必要

再検証の手段は、キャッシュの作り方に合わせて選びます。

  • パス単位で再検証する(revalidatePath()
  • データタグ単位で再検証する(revalidateTag()
  • そもそもキャッシュしないデータ取得として設計する

router.refresh() はこの一覧に含めていません。
現在のルートを再取得して表示を更新する操作であって、サーバー側のキャッシュを無効化するものではないためです。
表示だけ更新しても、キャッシュが古いままなら同じ内容が返ってきます。

重要なのは、DB更新とUI更新を別々に考えることです。

DBを更新した
  ≠
ブラウザの表示が必ず更新された

20. Server Actionsのセキュリティ

Server Actionは内部関数ではありません。

Client Componentから呼ばれるServer Functionは、ネットワーク経由で実行されます。

必ず次を検証します。

認証

誰が操作しているか

認可

そのユーザーが操作を実行できるか

所有権・テナント境界

対象データが本人または所属組織のものか

入力検証

型、長さ、形式、範囲、列挙値が正しいか

業務状態

現在の状態からその操作が許されるか

次に例を示します。

'use server'

export async function cancelReservationAction(
  reservationId: string,
) {
  const session = await requireSession()

  const reservation = await db.reservation.findUnique({
    where: {
      id: reservationId,
    },
  })

  if (!reservation) {
    return {
      success: false,
      message: '予約が存在しません',
    }
  }

  if (
    reservation.userId !== session.userId &&
    session.role !== 'ADMIN'
  ) {
    throw new Error('Forbidden')
  }

  if (reservation.status !== 'CONFIRMED') {
    return {
      success: false,
      message: 'この予約はキャンセルできません',
    }
  }

  await db.reservation.update({
    where: {
      id: reservationId,
    },
    data: {
      status: 'CANCELLED',
    },
  })

  revalidatePath('/reservations')

  return {
    success: true,
    message: 'キャンセルしました',
  }
}

画面にボタンを表示しないことはUX上の制御であり、認可ではありません。

21. Server Actionsをデータ取得に使わない

ReactのServer Functionsは、サーバー状態の更新を主目的としています。

次の用途には向きません。

  • 一覧の通常取得
  • 検索サジェスト
  • 高頻度ポーリング
  • 無限スクロール
  • 並列データ取得
  • クライアントキャッシュのQuery Function
  • IoTのテレメトリー取得

Server Componentから取得できる場合は次の経路になります。

Server Component
  → Query / Service
  → DB

ブラウザから取得する必要がある場合は次の経路になります。

Client Component
  → Route Handler
  → Query / Service
  → DB

Server Functionsは通常のGET APIの代替ではありません。

22. Server ComponentとClient Componentの合成

Server ComponentからClient Componentを使う

通常の構成です。

// Server Component
import { InteractiveButton } from './InteractiveButton'

export default async function Page() {
  const data = await getData()

  return (
    <InteractiveButton initialValue={data.value} />
  )
}

Client ComponentからServer Componentを直接importしない

'use client'

// 基本的に避ける
import { ServerProductList } from './ServerProductList'

Client Componentの依存ツリーはクライアント向けに扱われるため、サーバー専用処理を直接含められません。

Server Componentをchildrenとして渡す

これは可能です。

// Server Component
import { Modal } from './Modal'
import { ProductDetail } from './ProductDetail'

export default function Page() {
  return (
    <Modal>
      <ProductDetail />
    </Modal>
  )
}
// Client Component
'use client'

export function Modal({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <div role="dialog">
      {children}
    </div>
  )
}

ProductDetailはサーバー側で実行され、その結果がReact要素としてchildrenへ渡されます。

23. Server ActionsとRoute Handlersの関係

Server Actionsを採用しても、Route Handlerが不要になるわけではありません。

Server Action
  Next.js UIからの更新

Route Handler
  外部API
  Webhook
  OAuth callback
  ファイル配信
  SSE / Streaming
  CORSが必要なAPI
  モバイルアプリ向けAPI

両方から同じServiceを呼ぶ構成が実務的です。

Server Action ─┐
               ├→ Application Service → Repository → DB
Route Handler ─┘

Server ActionsとAPI方式は競合関係ではなく、異なる境界を担当します。

24. Server Actionsの注意点

1. 呼び出しは非同期

Server Functionはネットワーク通信なので、必ず非同期です。

2. 引数と戻り値に制約がある

Reactがシリアライズできる値だけを利用します。

3. 高頻度・並列取得には向かない

更新処理向けであり、取得APIとして利用しません。

4. JavaScriptバンドルを意識する

'use client'の位置が広すぎると、不要なJavaScriptがブラウザへ送られます。

5. 再検証戦略が必要

Action完了後に、どのキャッシュと画面を更新するかを設計します。

6. 認証済みページでもAction内の認可が必要

ページレベルのチェックだけでは不十分です。

7. Service層へロジックを分離する

Actionを巨大な業務ロジックの置き場所にしないようにします。

25. 実装時チェックリスト

コンポーネント境界

  • Server Componentで実装できる部分へ'use client'を付けていない
  • Client Componentを操作が必要な末端へ限定した
  • Client Componentへ渡すPropsがシリアライズ可能
  • Client Componentからサーバー専用モジュールをimportしていない

Server Action

  • Actionはasync関数
  • 引数を外部入力として検証している
  • Action内部で認証・認可を実施している
  • 所有権またはテナント境界を確認している
  • 想定内エラーは戻り値として表現している
  • 想定外エラーはError Boundaryへ渡している
  • 更新後の再検証方法を決めている
  • 業務ロジックをService層へ分離している

React 19 Hooks

  • Action結果の表示にはuseActionStateを検討した
  • 送信ボタンのPending表示にはuseFormStatusを検討した
  • useFormStatusを対象フォームの子で呼び出している
  • 楽観的更新が安全な処理だけuseOptimisticを利用している
  • useActionStateを通常のuseStateの代替として誤用していない

APIとの使い分け

  • 外部クライアントから呼ばれる処理はRoute Handlerにした
  • WebhookをServer Actionで受けようとしていない
  • ファイル・ストリーム・SSEはRoute Handlerを検討した
  • Server Componentから自分自身のAPIを呼んでいない
  • 高頻度のデータ取得にServer Actionを使っていない

26. まとめ

Server Actionsの本質を、単なる「API省略機能」として捉えると、使いどころを誤りやすくなります。

RSCとの関係を含めると、役割は次のように整理できます。

Server Component
  サーバー状態を読み取ってUIを生成

Client Component
  ユーザー操作とブラウザ状態を管理

Server Action
  ユーザー操作をサーバー状態へ反映

RSC Payload
  更新後のサーバーUIをブラウザへ伝達

React
  既存ツリーと新しいRSC Payloadを統合

最終的には、次の循環として理解すると分かりやすくなります。

時計回りの循環図。Databaseから読み取ってServer ComponentがUIを生成し、RSC PayloadとしてBrowser / Client Componentへ渡る。ユーザー操作がServer Actionへ渡って状態を更新し、再検証で1周してDatabaseへ戻る。DBを更新しただけでは画面は変わらない

テキスト版を開く
Database
  ↓ 読み取り
Server Component
  ↓ RSC Payload
Browser / Client Component
  ↓ ユーザー操作
Server Action
  ↓ 更新
Database
  ↓ 再検証
Server Component を再実行

Server Actionsは、RSCで生成されたUIに対し、サーバー状態を更新し、更新後のUIをReactツリーへ戻すための仕組みです。

参考資料

4
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?