Server Actionsがかなりキツイという話
Server Actionsがかなりキツイという話
さて,相も変わらぬフロントエンドは発展途上なわけでありますが,Next.jsのServer ActionsがStableになってしばらく経ちましたが,みなさまはいかがお過ごしでしょうか.
Server Actionsを本格的に使ってみたので例を上げながら所感を述べたいと思います.
タイトルに,「キツイ」という曖昧な表現をしましたが,ネガティブな意味はなく,概ねはServer Actionsの使用感には満足しています.ただ,今のところ手が届かないことが多いように感じました.
Server Actionsのベースとなる書き方
一般的にServer Actionsを使うのはCRUDのCUDです.
Next.jsのApp Routerでは,テータの取得にServer Actionsは使いません.(理由も割愛)
なので,サーバー側にフロントエンド側で扱っている何かしらのデータを送信したいときに使用します.
公式にあるシンプルな例がこちら
export default function Page() {
async function createInvoice(formData: FormData) {
'use server'
const rawFormData = {
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
}
// mutate data
// revalidate cache
}
return <form action={createInvoice}>...</form>
}
今回はこの形を維持して,本を新規作成するComponentを考えます.
export const CreateForm = () => {
const createBook = async (formData: FormData) => {
"use server";
const data = {
title: String(formData.get("title")),
};
await prisma.book.create({ data });
};
return (
<form action={createBook}>
<input name="title" />
<button type="submit">Submit</button>
</form>
);
};
本のデータの保存の処理はprismaを使用するものとし,
このComponentの責務は入力したデータをDBに保存してその結果をUIとして表示するものとし,
それらの責務はこのComponent内に閉じたいとします.
今はまだ本のデータを保存することをしか満たしていません.
サーバー側の処理をファイル分割したい
今回で言うprismaを使用したDBのCUDの処理は実際の開発ではファイル分割したいことでしょう.
こうなります.
"use client";
import {createBook} from '@/prisma/book' // 適当
export const CreateForm = () => {
return (
<form action={createBook}>
<input name="title" />
<button type="submit">Submit</button>
</form>
);
};
"use server";
export const createBook = async (formData: FormData) => {
const data = {
title: String(formData.get("title")),
};
/* Create */
await prisma.book.create({ data });
};
こんな感じです.
CreateFormはuse client
を宣言しないとエラーになったと思います.
サーバー側の処理結果をフロントエンドで知りたい
このままではデータの保存に成功したのかをフロントエンドは知ることができません.結果に適したUIを表示するためにもREST APIのResponseと同様の結果がフロントエンドにも必要になります.
さてここから書き方が人によって分かれてくるところです.
大きくは2つだと考えています.
useFormStateを使用するパターン(おそらく推奨)
1つはReactが提供するuseFormStateを使用する方法です.
これを使用することで,htmlだけでサーバーにデータを届けることができ,いろいろいいことがありそうです.
"use client";
import { createBook } from "@/prisma/book"; // 適当
export const CreateForm = () => {
const [formState, formAction] = useFormState(createBook, initialState);
return (
<form action={formAction}>
<input name="title" />
<button type="submit">Submit</button>
</form>
);
};
formStateというものを自分で定義することでハンドリングできます.この場合サーバー側の関数を以下のように書かなければなりません.
"use server";
export type FormActionState<
T,
U extends ZodType<any, any, any> = ZodType<any, any, any>,
> = {
data: T | null;
error: string | null; // その他のエラー
validationError?: ZodFormattedError<z.infer<U>> | null; // バリデーションエラー
};
export const createBook = async (
prevState: FormActionState<Book, typeof bookCreateSchema>, // 未使用の引数の状態
formData: FormData,
): Promise<FormActionState<Book, typeof bookCreateSchema>> => {
try {
const data: Prisma.BookUncheckedCreateInput = {
title: String(formData.get("title")),
url: String(formData.get("url")),
categoryId: Number(formData.get("categoryId")),
price: Number(formData.get("price")),
};
/* Validation */
const validated = bookCreateSchema.parse(data);
/* Create */
const book = await prisma.book.create({
data: validated,
});
return {
data: book,
error: null,
validationError: null,
};
} catch (error) {
if (isZodError(error)) {
return {
data: null,
error: null,
validationError: error.format(),
};
}
return {
data: null,
error: "Internal server error",
validationError: null,
};
}
};
おまけにバリデーションの処理も追加してみました.
これは一つの例ですが,このようにformState
の型は自由度が高くプロダクトごとに大きく異なることになるでしょう.
これはREST APIのようなResponseの形式をこちらで決めるということです.
今回は
data: T | null; // 成功した場合に該当のデータが入る
error: string | null; // その他のエラー
validationError?: ZodFormattedError<z.infer<U>> | null; // バリデーションエラー
のようにしました.
error
とvalidationError
は統合してもいいかと思います.
こうすることで,サーバー側の実行結果をformState
として受け取ることができるようになり,エラーを動的に表現することができます.
例えばValidateErrorは,
<p>{formState.validationError?.title?._errors.join(" ")}</p>
のように書けば動的に表現できます.
しかし,JSXにそのまま書く類のものは動的に表現できますが,結果による処理を実行する方法が私は未だに見つかっていません.
思いつく方法は以下のようにuseEffect
を使用して処理を走らせる苦肉の策です.
useEffect(() => {
if (formState.data) {
notifications.show({
title: "Success",
message: "Book created!!",
});
router.back();
}
if (formState.error) {
notifications.show({
title: "Error",
message: formState.error,
color: "red",
});
}
}, [formState, router]);
また,この場合でさらにLoading状態をUIで表現したいとします.
その場合は<button type="submit">Submit</button>
の部分をComponent分割しなければなりません.
以下のようにして,useFormStatus
を使用することで実現できます.
import { useFormStatus } from "react-dom";
export const SubmitButton = ({ children }) => {
const status = useFormStatus();
return (
<button type="submit" disabled={status.pending}>
{children}
</button>
);
};
という実装が推奨されている方法のようです.
縛られる部分と自由すぎる部分は混ざり合っており,開発体験はなんとも言えない感じになりそうです.
jsで自由にハンドリングするパターン
続いては上のパターンを知らない人が思いつきそうなパターンです.
"use client";
import { createBook } from "@/prisma/book"; // 適当
export const CreateForm = () => {
const handleAction = async (data: FormData) => {
/* ここでハンドリング */
try {
const book = await createBook(data); // ここだけサーバーで実行されることに注意
// 成功時の処理
} catch (error) {
// エラー時の処理
}
};
return (
<form action={handleAction}>
<input name="title" />
<button type="submit">Submit</button>
</form>
);
};
これであれば,Loadingやエラーすべてのパターンをいつものように記述できそうです.
デメリットを上げれば,
HTMLだけで実行する恩恵を得られないというのと,
createBookの処理だけがサーバーサイドであるということがわかりずらくカオスであることでしょうか.
また,web上を探してもこのように実装している例は見つかりませんでした.
Optimistic updatesを使用するパターンもある
また,Optimistic updatesを使用する手法もありますが,試してないのと,実装に複雑味が増すので好ましくないと考えているので,今回は省きました.
あとはuseTransition
を使用してonSubmitする方法もあったような.
もうキツイ
いかがでしょうか.
何がキツイのかはあえて書きませんが,
実際に実装するともっと多くのことを考えなければならずに更にキツイことが増えるかと思います.
しかし,これらは今までの固定概念があるとよりキツイと感じてしまうとも言いかえられます.
個人的には
- フロントとサーバーで型を一貫して使用できる
- APIのエンドポイントを作成しなくて済む
- フロントエンドのバリデーションが不要になった
- フロントエンドのuseFormという概念が不要になった
という点だけで非常に開発体験は良いと感じました.
また,これまで複雑に実装しがちであったフロントエンドもこれによってシンプルにすることもできると考えています.
Discussion
わかりずらく→ わかりづらく
固定概念 → 固定観念