ReactのServer Functionsでフォームの送信を受け取る方法

​​React​Next.js,TypeScript,useActionState,フォーム

React 19 の Server Functions を使うと、フォームの送信をサーバーで受け取る処理を、関数1つで書けます。'use server' を書いた非同期の関数を <form action> に渡すと、送信のたびにサーバーでその関数が呼ばれ、入力が FormData で届きます。Next.js 16 で小さなアプリを作り(API のルートは作っていません)、送信のリクエストの形、JavaScript がないときの動き、エラーの返し方を確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Next.js 16.4.3(App Router、ビルドは Turbopack)、React 19.3.1、TypeScript 7.0.2、ブラウザは Chromium(Chrome for Testing 153)です。確かめたのは本番用のビルド(next build)と next start だけで、開発用サーバー(next dev)、Next.js 以外のフレームワーク、複数のサーバーに分けて動かす構成は試していません。

'use server' のファイルに関数を書き、form の action に渡す

'use server' のリファレンスによると、ファイルの先頭に 'use server' を書くと、そのファイルから export した関数がすべて Server Functions になります。ネットワーク越しに呼ばれるので、非同期の関数でなければいけません。

検証では、猫の名前を一覧に足すフォームを作りました。一覧は data/cats.json に保存し、lib/cats.ts の readCats と writeCats で読み書きします。Server Function は次のように書きました。

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

import { revalidatePath } from 'next/cache';
import { readCats, writeCats } from '../lib/cats';

export async function addCat(formData: FormData) {
  const name = formData.get('name');
  console.log('[addCat]', [...formData.keys()], name);
  if (typeof name !== 'string' || name.trim() === '') return;

  const cats = await readCats();
  await writeCats([...cats, name.trim()]);
  revalidatePath('/');
}

formData.get は、その名前の項目がなければ null を返します。FormData の値は、文字列か、ファイル(Blob や File)のどちらかです。文字列でないときや空のときは、何もせずに終えるようにしました。

ページは Server Component のままで、addCat を import して action に渡します。

tsx
// app/page.tsx
import { addCat } from './actions';
import { readCats } from '../lib/cats';

export default async function Page() {
  const cats = await readCats();
  return (
    <main>
      <ul>
        {cats.map((name, i) => (
          <li key={i}>{name}</li>
        ))}
      </ul>
      <form action={addCat}>
        <input name="name" aria-label="名前" />
        <button>追加</button>
      </form>
    </main>
  );
}

送信すると、同じ URL への POST が1回だけ飛ぶ

検証環境(Ubuntu 24.04、Node.js 22、Chromium)で、入力欄に「みけ」と入れて「追加」を押したところ、ブラウザから出たリクエストは次の1回でした。

text
POST の数: 1
method: POST  url: http://localhost:3000/  種類: fetch
next-action ヘッダー: 40d4a2b83b25a6c69b3b7d004d6adc7e4c99bb5c40
content-type: multipart/form-data; boundary=(省略)

送り先は、別の API の URL ではなく、ページと同じ / でした。ブラウザは、どの Server Function を呼ぶかを Next-Action というヘッダーで伝えています。ヘッダーの値は、ビルドのときにクライアント向けのコードで関数の代わりに置かれるアクション IDで、下のリクエストの本文にある $ACTION_ID_ の後ろと同じ文字列でした。リクエストの本文には、入力欄の値が名前付きで入っていました(区切りの文字列は省略しています)。

text
----(区切り)
Content-Disposition: form-data; name="_1_$ACTION_ID_40d4a2b83b25a6c69b3b7d004d6adc7e4c99bb5c40"


----(区切り)
Content-Disposition: form-data; name="_1_name"

みけ
----(区切り)
Content-Disposition: form-data; name="0"

["$K1"]
----(区切り)--

応答は 200 text/x-component でした。一覧は「たま」「みけ」の2匹になり、入力欄は空に戻りました。検証のスクリプトが送信の前に window に入れておいた値は、送信後も残っていました。ページの読み込みも1回だけでした。ページを読み込み直さずに、一覧だけが新しくなったということです。

addCat の中の console.log は、サーバー側(next start を動かしている端末)に次のように出ました。

text
[addCat] [ '$ACTION_ID_40d4a2b83b25a6c69b3b7d004d6adc7e4c99bb5c40', 'name' ] みけ

FormData には、入力欄の name のほかに $ACTION_ID_ で始まる項目が入っていました。Next.js のフォームのガイドにも、Object.fromEntries(formData) でまとめて取り出すと $ACTION_ で始まる項目が混ざると書かれています。項目は formData.get('name') のように名前を指定して取り出すのが確実です。

JavaScript がなくても、同じフォームで送れる

<form> のリファレンスには、Server Component が描画した <form> に Server Function を渡すと、JavaScript が無効なときや読み込まれる前でも送信できると書かれています。Next.js のドキュメントにも、Server Components のフォームは JavaScript なしで送信されるとあります。

ブラウザの JavaScript を切って同じページを開くと、フォームは次の HTML になっていました。

html
<form action="" enctype="multipart/form-data" method="POST">
<input type="hidden" name="$ACTION_ID_40d4a2b83b25a6c69b3b7d004d6adc7e4c99bb5c40">
<input aria-label="名前" name="name">
<button>追加</button>
</form>

どの関数を呼ぶかは、ヘッダーの代わりに隠し項目の名前で送られます。「くろ」と入れて送ると、ブラウザは普通のフォーム送信として / へ POST し、応答は 200 text/html でした。ページ全体が読み込み直され、一覧は「たま」「みけ」「くろ」の3匹になりました。サーバーの関数に届いた FormData の項目は name だけでした。

JavaScript ありとなしの2段の図。上の段では、ブラウザが Next-Action ヘッダー付きの POST を fetch で送り、サーバーの addCat が text/x-component を返して、ページはそのまま一覧だけが変わる。下の段では、ブラウザが隠し項目 $ACTION_ID_ 付きの普通のフォーム送信をして、サーバーが text/html を返し、ページ全体を読み込み直す
同じ form action={addCat} が、JavaScript の有無で2通りの送り方になる

静的なページでは、revalidatePath を呼ばないと一覧が古いまま残る

next build の結果では、このページは静的に事前描画されるページ(○ /)になっていました。addCat から revalidatePath('/') の行だけを消してビルドし直し、JavaScript ありで送ると、data/cats.json には「みけ」が足されたのに、画面の一覧は「たま」だけのままでした。ページを読み込み直しても変わりませんでした。

text
送信から1秒後の li: [ 'たま' ]
再読み込みした後の li: [ 'たま' ]

Next.js の Server Actions のガイドによると、revalidatePath は、指定したパスのキャッシュを無効にします。Server Action(Next.js で、フォームの送信などに使う Server Function の呼び方)の中で呼ぶと、今のルートをサーバーで描画し直し、その結果を同じ応答に入れて返します。データを書き換えた後は、表示に使っているパスを revalidatePath で指定します。

入力のエラーを返すときは useActionState を使う

Server Function の戻り値を受け取って画面に出すには、useActionState を使います。React のリファレンスでも、送信中の状態や最後の戻り値を使いたいときは useActionState を足すよう説明されています。Next.js のガイドでは、入力のエラーを表示するときはフォームを Client Component にして useActionState を使うとしています。このとき、Server Function は1つ目の引数で前回の状態を、2つ目の引数で FormData を受け取ります。

ts
// app/actions.ts(続き)
export type AddCatState = { error: string | null; name: string };

export async function addCatWithState(
  prevState: AddCatState,
  formData: FormData,
): Promise<AddCatState> {
  const name = formData.get('name');
  console.log('[addCatWithState]', prevState, name);
  if (typeof name !== 'string' || name.trim() === '') {
    return { error: '名前を入れてください', name: '' };
  }
  if (name.length > 10) {
    return { error: '名前は10文字までです', name };
  }

  const cats = await readCats();
  await writeCats([...cats, name.trim()]);
  revalidatePath('/with-state');
  return { error: null, name: '' };
}

エラーのときは、送られた名前も状態に入れて返します。フォームの側では、それを入力欄の defaultValue に入れます。

tsx
// app/with-state/cat-form.tsx
'use client';

import { useActionState } from 'react';
import { addCatWithState } from '../actions';

export function CatForm() {
  const [state, formAction, isPending] = useActionState(addCatWithState, {
    error: null,
    name: '',
  });

  return (
    <form action={formAction}>
      <input name="name" aria-label="名前" defaultValue={state.name} />
      <button disabled={isPending}>追加</button>
      {state.error && <p role="alert">{state.error}</p>}
    </form>
  );
}

ページ(app/with-state/page.tsx)は、/ と同じように一覧を描画し、その下に <CatForm /> を置きました。空の名前、12文字の名前、「しろ」の順に送った結果は次のとおりです。送信中の様子を見るため、検証のスクリプトで POST を 500 ミリ秒遅らせています。

送った名前 送信中のボタン 表示されたエラー 送信後の入力欄 一覧
(空) 押せない 名前を入れてください 空 たま
ながいながいなまえのねこ 押せない 名前は10文字までです 送った名前が残る たま
しろ 押せない なし 空 たま、しろ

サーバーの出力を見ると、2回目と3回目の呼び出しでは、1つ目の引数に前回の戻り値が入っていました。

text
[addCatWithState] { error: null, name: '' } 
[addCatWithState] { error: '名前を入れてください', name: '' } ながいながいなまえのねこ
[addCatWithState] { error: '名前は10文字までです', name: 'ながいながいなまえのねこ' } しろ

JavaScript がなくても、エラーが表示される

<form> のリファレンスでは、JavaScript が読み込まれる前にエラーを表示するには、フォームを Client Component が描画し、Server Function を useActionState と組み合わせることが条件とされています。このフォームはその形です。

JavaScript を切って開くと、フォームの HTML には、呼ぶ関数の情報と初めの状態({"error":null,"name":""})が隠し項目として入っていました。

html
<form action="" enctype="multipart/form-data" method="POST">
<input type="hidden" name="$ACTION_REF_1">
<input type="hidden" name="$ACTION_1:0" value="{&quot;id&quot;:&quot;605bcec8199f68a777859c5a5919ba3f1ec05320ce&quot;,&quot;bound&quot;:&quot;$@1&quot;}">
<input type="hidden" name="$ACTION_1:1" value="[{&quot;error&quot;:null,&quot;name&quot;:&quot;&quot;}]">
<input type="hidden" name="$ACTION_KEY" value="k625ff5bee164c3ceca102f434eb10857">
<input aria-label="名前" name="name" value="">
<button>追加</button>
</form>

12文字の名前を送ると、読み込み直したページに「名前は10文字までです」が表示され、入力欄には送った名前が残っていました。サーバーの関数には、1つ目の引数として、隠し項目にあった初めの状態が渡っていました。

フォームを通さない POST でも呼ばれるので、入力と権限は関数の中で確かめる

Next.js のドキュメントは、Server Functions がアプリの画面を通さない POST でも直接呼べるので、関数の中で認証と権限を確かめるよう注意しています。React のリファレンスも、引数はすべてクライアントが自由に決められる値として扱うよう求めています。

検証では、ページの HTML から $ACTION_ID_ の値を取り出し、curl で JavaScript なしのフォームと同じ形の POST を送りました。addCat には長さの確認を入れていないので、30文字の名前がそのまま保存されました。スクリプトの出力は次のとおりです(抜き出しです)。

text
# (1) Origin ヘッダーなしで、30文字の名前を送る
HTTP 200
# (2) 別のサイトの Origin を付けて送る
HTTP 500
# 送った後の data/cats.json
["たま","しろ","ねこねこねこねこねこねこねこねこねこねこねこねこねこねこねこ"]

別のサイトの Origin を付けた2回目は、関数が呼ばれずに断られました。サーバーには次のメッセージが出ました(抜き出しです。長い行は途中で折り返しています)。

text
`x-forwarded-host` header with value `localhost:3000` does not match `origin` header
with value `attacker.example` from a forwarded Server Actions request. Aborting the action.
⨯ Error: Invalid Server Actions request.

Next.js は、リクエストの Origin と Host が合わないものを断ります。ただ、curl のように Origin を付けないリクエストは、サーバーに警告(Missing `origin` header from a forwarded Server Actions request.)が出ただけで、そのまま通りました。入力欄に maxLength を付けるといった画面側の制限は、こうした送信には効きません。長さや形式の確認は、addCatWithState のように Server Function の中に書きます。ログインしている人がその操作をしてよいかの確認も、同じく関数の中で行います。

ポイント

Server Function でフォームの送信を受け取るときのポイントは、次の4つです。

  • 'use server' のファイルから export した非同期の関数を <form action> に渡し、formData.get('name') のように名前で値を取り出す。
  • データを書き換えた後は、表示に使っているパスを revalidatePath で指定する。
  • エラーを画面に出すときは、Client Component のフォームで useActionState を使い、関数は (prevState, formData) を受け取る形にする。
  • 関数は画面を通さなくても呼べるので、入力の確認と権限の確認は関数の中で行う。

​Posted by 86