Closed5

Server Actions を触る

Yo IwamotoYo Iwamoto

前提

Server Actions は、実装上 API 境界を(ほぼ)意識しなくて良くなるが、実際には FormData を POST するインターフェースがどうしても一般に露出してしまうので、安全に DB アクセスなどをするにはバリデーションが必要。

オーソドックスな使用方法としては例えば以下のように Server Components で action 関数のトップレベルで 'use server'; ディレクティブで定義する方法が示されているが、

PostEdit.tsx
export function PostEdit({ id, post }) {
  async function updatePost(formData) {
    'use server';

    await prisma.post.update({ where: { id }, data: { title: formData.title } });
  }

  return (
    <form action={updatePost}>
      <input type="text" name="title" defaultValue={post.title} />
    </form>
  );
}

その次の例にあるように、トップレベルで 'use server'; が宣言されている他のファイルで定義された action 関数を Client Component で import して使用できる。

_action.ts
'use server';

export async function updatePost(formData) {
  await prisma.post.update({ where: { id }, data: { title: formData.title } });
}
PostEdit.tsx
'use client';
import { updatePost } from './_action';

export function PostEdit({ id, post }) {
  return (
    <form action={updatePost}>
      <input type='text' name='title' defaultValue={post.title} />
    </form>
  );
}

Yo IwamotoYo Iwamoto

実装上(ほぼ)API 境界を意識せずに書けるようになるとはいえ、非同期処理なので実際には POST で FormData を処理する API が露出することにはなる。
以下は、Server Actions に対して直接 POST リクエストを送信して、INSERT に成功している図。

直接 POST リクエストを送信して、INSERT に成功している様子のスクリーンショット

一応、Request Headers の Next-Action に出所不明のハッシュ値、Next-Url にページのパスが入っていたりしたのでこれはブラウザから送ったリクエスト内の値を拝借しているが、まあ取れないことはないし、Next-Action についても普通に予測可能な値かもしれない。

Yo IwamotoYo Iwamoto

ということで action 側でもやはりバリデーションを行う必要がある。
Server Actions は、基本的に React の <form>action<form> 内の <button> 等の formAction 上で実現されているが、<form> に関連しないところでも使用することができて、今回はこれを使用する。

T3 Stack の Theo 氏の pingdotgg/zact は Server Actions を zod で縛って型安全にしようとしていて、これを利用する。
https://github.com/pingdotgg/zact

Yo IwamotoYo Iwamoto

先に書いたように Server Actions の関数は import して Client Component で使用できる。
一般的に、要件がリッチになっていった昨今のフロントエンドアプリケーションでは、場合によっては onBlur 等でのリアルタイムバリデーションが必要なので、react-hook-form にはまだ引き続きお世話になりそうで、これは Server Components では動かないので Client Component でフォーム実装をする。

まず、以下のように Client Component、Server Actions で使い回す zod schema を schema.ts に定義する。

schema.ts
import { z } from 'zod';

export const formSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
});

export type FormSchema = z.infer<typeof formSchema>;

次に、Client Component としてこれまでと同様のフォームを実装します。

Form.tsx
'use client';

import { formSchema } from './schema';
import { Controller, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import type { FormSchema } from './schema';

export function Form() {
  const { control, handleSubmit } = useForm<FormSchema>({
    resolver: zodResolver(formSchema),
    defaultValues: { name: '', email: '' },
  });

  const onSubmit = handleSubmit((data) => {
    console.log(data);
  });

  return (
    <form onSubmit={onSubmit}>
      <Controller
        control={control}
        name='name'
        render={({ field }) => <input type='text' {...field} />}
      />
      <Controller
        control={control}
        name='email'
        render={({ field }) => <input type='email' {...field} />}
      />

      <button type='submit'>submit</button>
    </form>
  );
}

次に、 Server Actions を定義します。
例でファイル名に _ の prefix が付いていたので倣ってみましたが、これは実装上特に関係ありません。

_action.ts
'use server';

import { formSchema } from './schema';
import { zact } from 'zact/server';

export const registerAction = zact(formSchema)(async (data) => {
  console.log(data); // { name: <name value>, email: <email value> }

  return { message: 'OK', status: 201 };
});

ここで、zact/server の zact を使っています。
引数にバリデーションのための zod schema を渡し、parse された値を受け取って server action を行う関数を返します。

次にこれを Client Component で使用します。

  • action を import してハンドラ内で呼び出し。
  • 非 form での Server Actions の呼び出しは React に update を伝えるため startTransition を使用する。
  • 必要に応じて isPending で UI を制御。
Form.tsx
'use client';

import { formSchema } from './schema';
+ import { registerAction } from './_action';
import { Controller, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
+ import { useTransition } from 'react';
import type { FormSchema } from './schema';

export function Form() {
+   const [isPending, startTransition] = useTransition();

  const { control, handleSubmit } = useForm<FormSchema>({
    resolver: zodResolver(formSchema),
    defaultValues: { name: '', email: '' },
  });

+  const onSubmit = handleSubmit((data) => {
+    startTransition(async () => {
+      const response = await registerAction(data);
+      console.log(response); // { message: 'OK', status: 201 }
+    });
+  });

  return (
    <form onSubmit={onSubmit}>
      <Controller
        control={control}
        name='name'
        render={({ field }) => (
+           <input type='text' {...field} disabled={isPending} />
        )}
      />
      <Controller
        control={control}
        name='email'
        render={({ field }) => (
+           <input type='email' {...field} disabled={isPending} />
        )}
      />

+       <button type='submit' disabled={isPending}>
        submit
      </button>
    </form>
  );
}

この構成だと、フロントエンドでのバリデーションはこれまで通りある程度リッチに作り込んだ状態のまま Server Actions を利用できそうです。

実装上ほとんど API 境界を意識せずに関数としてバックエンド処理を実行できます。
採用できる場面が多いかどうかは今後の様子を見ないと分かりませんが、特に型周りでは気にすることが一気に減りそうな印象です。

今素振りでなんとなく書いてるアプリでは上の書き方で使ってます。
https://github.com/you-5805/kaite.dev/blob/main/src/components/pages/NewArticleIdeaPage/index.tsx

Yo IwamotoYo Iwamoto

ちなみに zact/client では useZact という、zact/server で定義した action を使うための hook が提供されていますが、こちらは特に多くのことをしているわけではなく、使わないことも可能です。
useZact では action の返り値を swr のように宣言的に扱う API になっているんですが、(個人的には)多くの場合 Server Actions の実行のスコープ内でそのまま関数のように返り値を使いたいことが多いと思うので、噛み合わず今回は使っていません。

zact/client の実装
https://github.com/pingdotgg/zact/blob/main/packages/zact/client.ts

このスクラップは2023/06/21にクローズされました