Server Actionsに追加のパラメータを渡す方法
目次
Next.js 14ではAPIルートを作成せずにサーバ側のコードを実行することができるServer Actionsという機能が追加されました。Server Actionsの実体はサーバ上で実行される非同期関数です。API RouteやRoute HanldersのようにHTTPレスポンスを返すものではありませんが、値は返すことはできます。Route Hanldersと使用場面が被る面もありますが、一般的にMutationはServer Actions、QueryはRoute Hanldersで行うのが良いようです。
Server Actionsの呼び出し
Server Actionsの呼び出しはForm
のアクションとそれ以外に分けられます。
前者はaction属性を使用するため、PHPなどのサーバスクリプトの呼び出しに似ています。後者は従来の関数の呼び出しと同じです。Server Actionsを呼び出す際には他にもstartTransitionやuseOptimisticを使う方法もありますが、根本は後者です。Formアクションが特殊で、以降の内容はForm
に焦点を当てています。
Form Actions
Form
からの呼び出しの場合は通常、useFormState
を使用してアクションの結果を受け取ることになると思います。引数にはアクションと初期状態を渡します。
また、以下ではuseFormStatus
も併用し、ボタンの有効化・無効化をスイッチしています。
"use client";
import { useFormState, useFormStatus } from "react-dom";
import { myAction } from "./actions";
const initialState = {
message: null,
errors: {},
};
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" aria-disabled={pending} disabled={pending}>
Add
</button>
);
}
export function MyForm() {
const [state, formAction] = useFormState(myAction, initialState);
return (
<form action={formAction}>
<input type="text" name="todo" required />
<SubmitButton />
{state?.message && (
<p>{state.message}</p>
)}
</form>
);
}
このときアクションは以下のような引数を取ります。
export async function myAction(prevState: any, formData: FormData) {
return {
message: "ok",
errors: {}
};
}
prevStateは投稿前の状態を受け取ります。ここではinitialState
です。formData
はFormDataに準拠した投稿データを受け取ります。また、useFormState
を使用しない場合はformData
のみを引数として受け入れます。
このときmyAction
アクションに追加のパラメータを渡したい場合、どうすればいいでしょうか?
方法
以下の4つの方法が考えられます。
- bindを使用する
- 隠しパラメータを設定する
- FormDataでパラメータを渡す
- Route Handlersを使用する
1. bindを使用する
公式に記載されている方法です。アクションにbind
を使用して、引数を渡すだけです。
"use client";
import { useFormState, useFormStatus } from "react-dom";
import { myAction, state } from "../actions";
export function MyForm() {
const initialState: state = {
message: null,
errors: {},
};
const myId = 10;
const myActionWithId = myAction.bind(null, myId);
const [state, dispatch] = useFormState(myActionWithId, initialState);
return (
<form action={dispatch}>
<input type="text" name="todo" required />
<SubmitButton />
<div id="todo-error" aria-live="polite" aria-atomic="true">
{state?.error?.todo &&
state?.errors?.todo.map((error) => <p key={error}>{error}</p>)
}
{state?.message && <p>{state.message}</p>}
</div>
</form>
);
}
Server Actionsでは、指定した引数を第一引数で受け取ることができます。
注意点は、フォーム側の初期状態値とアクション側のstateと一致させることです。また、zodを使用している場合は、バリデーションチェック時のエラー結果をstate.errors
に格納することになるので、このプロパティ名もstate typeに用意します。これらを満たさないと、ESLintは警告を表示します。
以下はアクションの抜粋です。
import { z } from "zod";
export type state = {
errors?: {
todo?: string[];
};
message?: string | null;
};
const todoSchema = z.object({
todo: z.string().min(1),
});
export async function myAction(
myId: number,
prevState: state,
formData: FormData
) {
console.log("追加パラメータ:", myId); //10
const parse = todoSchema.safeParse({
todo: formData.get("todo")
});
//...
}
2. 隠しパラメータを設定する
隠しパラメータ用のinput
を用意してフォーム投稿するという方法です。
export function MyForm() {
const [state, formAction] = useFormState(myAction, initialState);
return (
<form action={formAction}>
<input type="text" name="todo" required />
{/* 隠しパラメータを設定する */}
<input type="hidden" name="myId" value="10" />
<SubmitButton />
{state?.message && <p>{state.message}</p>}
</form>
);
}
アクションではFormData
のAPIを介してアクセスができます。
export async function myAction(prevState: any, formData: FormData) {
const id = formData.get("myId"); //10
return {
message: "ok"
};
}
これでuseFormState
を使いつつ、追加のパラメータをアクションに渡すことができました。
ただし、問題があります。隠しパラメータはHTMLのため、ユーザが編集できてしまいます。ブラウザ開発ツールで以下のようにパラメータを変更されてしまう可能性があるということです。
<input type="hidden" name="myId" value="10000000" />
これは危険です。他に何かないでしょうか?
3. FormDataでパラメータを渡す
これもFormDataを使った方法ですが、HTMLとしては書き出されません。
export function MyForm() {
const [state, formAction] = useFormState(myAction, initialState);
const myId = 10;
return (
<form action={async (formData: FormData) => {
// 追加のパラメータを与える
formData.append("myId", myId.toString());
formAction(formData);
}}>
<input type="text" name="todo" required />
<SubmitButton />
{state?.message && <p>{state.message}</p>}
</form>
);
}
formData.append()
はFormDataのメソッドです。これで追加のパラメータを与え、その後アクションを呼び出します。パラメータはFormの値として送信されます。送信できるデータはStringもしくはBlobとなるためtoString()
を呼び出しています。
アクションは以下のようになります。FormData.get()
で上記で追加したmyId
を取得しています。
export async function updateTodo(prevState: any, formData: FormData) {
const myId = FormData.get("myId"); //10
return {
message: "ok"
};
}
ネットワークを見るとPOSTリクエストのbodyにパラメータが送付されているのが確認できます。
-----------------------------98832495636292646512463051946
Content-Disposition: form-data; name="todo"
todo
-----------------------------98832495636292646512463051946
Content-Disposition: form-data; name="myId"
10
-----------------------------98832495636292646512463051946
Content-Disposition: form-data; name="0"
[{"message":""},"$K1"]
-----------------------------98832495636292646512463051946
4. Route Handlersを使用する
元も子もない話ですが、そもそもServer ActionsではなくRoute Hanldersを使用すれば、見慣れた形で実現可能です。
export function MyForm() {
//...
return (
<form
action={async (formData: FormData) => {
const response = await fetch("/api/route-handler", {
method: "POST",
headers: {
"Context-Type": "application/json",
},
// bodyに追加データを渡す
body: JSON.stringify({ myId: 10 }),
cache: "no-store",
next: { tags: ["anyTag"] },
});
const data = await response.json();
console.log("resive:", data);
}}>
<input type="text" name="todo" required />
<UpdateButton />
</form>
);
}
Route Hanldersは以下のようになります。
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const res = await request.json();
console.log(res); // { myId: 10 }
return NextResponse.json({ message: "OK" }, { status: 200 });
}
この場合もuseFormState
は使用できます。
その他
サーバアクションの呼び出し元がForm
ではない場合はactionを使用する必要はないため、呼び出し時に引数を与えることができます。
<button onClick={async () => {
const result = await serverAction(param1, param2);
//...
}}>
Button
</button>
ただし、input
を使う場合はHTMLのルールとしてform
を用いる必要があるため、前述したいずれかの方法を取る必要があります。