homehome

Server Actionsに追加のパラメータを渡す方法

Published

TocMenu目次

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を呼び出す際には他にもstartTransitionuseOptimisticを使う方法もありますが、根本は後者です。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です。formDataFormDataに準拠した投稿データを受け取ります。また、useFormStateを使用しない場合はformDataのみを引数として受け入れます。

このときmyActionアクションに追加のパラメータを渡したい場合、どうすればいいでしょうか?

方法

以下の4つの方法が考えられます。

  1. bindを使用する
  2. 隠しパラメータを設定する
  3. FormDataでパラメータを渡す
  4. 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を用いる必要があるため、前述したいずれかの方法を取る必要があります。