🛳️

《凄い!》Server ActionsでReact・Nextが目指す方向性についての考察、劇的に変わるform周り-VercelShip

2023/05/05に公開

※↓の記事もありますが、 Server Actions だけで別記事にしました。
https://zenn.dev/rgbkids/articles/f0aebc5b94c17a


VTeacher所属のSatokoです。

QAエンジニアとフロントエンドエンジニアを兼任しています。
(最近は割とFE多めです)

https://youtu.be/M4vrwI5PDI0?t=880

(Vercel Ship 4日目)

Server Actions が正式に発表されました。
"use server" の謎が解け、良い意味でも悪い意味でも、とにかく反響が大きかったという印象です。

https://twitter.com/levelsio/status/1654053489004417026

ある意味、
Facebook社(現Meta社)から React Server Components が発表されたあとと似ている感じがしていまして、
Server Actions の登場で現実味を帯びてきたために再加熱というところだと思います🤔

Reacet Server Comonents ・・・ 略してRSC。
RSCは2020年末にMeta社(旧Facebook社)が発表し、その後、Vercel社とプロジェクトを進めています。

https://beta.nextjs.org/docs/rendering/server-and-client-components

結局、RSCとは何なのでしょうか?

まず先にですが、Meta社のフロントエンドチームについての印象です。
もともとFacebook ( https://facebook.com/ ) はPHPで作っていたこともあってか、フロント担当であってもバックエンドエンジニア的な視点も併せ持ち、パフォーマンスについてもかなり重視している印象です。

https://www.youtube.com/watch?v=8pDqJVdNa44

歴史的に次のような経緯と挑戦があり、
(時系列的に1→2→3の順)

  1. PHP(サーバーサイドレンダリング)でクライアントサイドも作ろう
  2. クライアントサイドはJavaScript(React)で作ろう
  3. JavaScript(React)をPHPのようにサーバーサイドでレンダリングさせよう

どれも課題があったので、
今度は「コンポーネント単位でサーバーサイドレンダリングかクライアントサイドレンダリングを選択できるようにしよう!」という挑戦をしているのです。
それが Reacet Server Comonents (RSC) という理解です。

Meta社は一貫して、次のことに注力している印象です。

  • 操作性(過去にあった ネイティブアプリ vs HTML5)
  • 性能(とくに表示速度におけるパフォーマンス)
  • 生産性(エコシステムの開発)

どれも特別なことではなく、ある意味「普通」のことを黙々とやっているという印象です。
(規模だけが違う)

しかしここに、主に利用者からの熱い思いが入って議論が加熱します。

  • それは本当にクールなの?
  • 時代に逆行していない?
  • 今のままじゃダメなの?

今回は Server Actions で熱い議論が発生しました。

冒頭の「(PHPに戻すの?)」は、
言い得て妙とは言い難いのかなと個人的には思います🤔
ただ、結果として「(PHPに戻ったね)」という印象にはなるかもしれません。

今がターニングポイントということは間違いないと思います。
(ターニングポイントというなら、それはもうとっくに過ぎているかもしれませんが😅)

Server Actions の概要

Server Actions はまだアルファ版です。

フォーム周りの処理ですが、今までの作り方だと抽出・登録・更新・削除用のAPIを用意せざるを得ないという状況でした。
しかし Server Actions の登場により、今までの煩わしさがなくなり、サーバーコンポーネント内でformデータを扱えば良いだけになります。form周りの扱い方が劇的に変わりそうです。

ポイント

関数内にある 'use server' がポイントで、Server Actions を使う時のひとつの目印です。

Server Actions の関数を import して使うこともできます。

// 'use server'; // exportして使う場合は←ココに書いても良い

export async function create(formData: FormData) {
  'use server';
  console.log(formData);
}

Server Actions はサーバーコンポーネントでもクライアントコンポーネントでも使用できます (後述)。

'use server' の記述を忘れると、エラーになります。

サーバーコンポーネントはサーバー側で処理されるので、DBの操作(ORM)も選択肢に入れられます。クライアント側にあるフォームデータを、(プログラム上、)サーバー側で直接取り扱えるようにしたことは、個人的には大賛成です!

https://twitter.com/dan_abramov/status/1654132342313701378

より具体的な Server Actions は以下をご覧ください!

目次

作ってみたもの

弊社のエンジニアが試しに作ってみたものです。

  • Next 13.4.1
  • nrstate (状態管理)
  • Server Actions
  • Zod (バリデーション)

https://nrstate-demo.vercel.app/demo/

※スマホで見る場合は、向きのロック🔐を外して、横向きでみてください!

Documents

試してみる

試すにはNext.js 13.4.1 をインストールし(最新版はnpmでも公開されました)、
next.config.jsserverActions: true にします。

const nextConfig = {
  experimental: {
    serverActions: true,
  },
};

※ ちなみに Next.js 13.4 から appDirはstableになった ので appDir: true の記述は不要です。

弊社のエンジニアが試したコードはこちらです。

# Install dependencies:
pnpm install

# Start the dev server:
pnpm dev

Case1: もっともシンプルな Server Actions

  1. サーバーコンポーネントの中で <form> をレンダリングします。
  2. <form> の action に紐づける関数( = Server Actions )を記述します。
  3. Server Actions の関数内に 'use server' を記述します。

SPA全般に言えるかもしれませんが、今まではMutate系(登録・更新・削除など)の処理をすっきりと書くことが難しかったです。
必ずAPIを用意し、クライアントで onClick をハンドリングし、フォームデータを payload してサーバーに送信していました。

Server Actions の登場で、このあたりの処理方法が劇的に変わったと思います。
サーバーコンポーネントらしさを享受できますし、何しろ直感的に記述できるようになっています。

// ...(略) ※サーバーコンポーネント

export default async function B() {

  // ...(略)

  async function fServerAction(formData: FormData) {
    'use server';

    console.log(formData);
  }

  return (
    <ul>
      {examples.map(
        ({ id, name }: { id: string; name: string; }) => (
          <li key={id}>
            {/* @ts-expect-error Async Server Component */}
            <form action={fServerAction}>
              <input type="text" name="id" defaultValue={id} />
              <input type="text" name="name" defaultValue={name} />
              <button type="submit">
                Try Server Actions
              </button>
            </form>
          </li>
        ),
      )}
    </ul>
  );
}

Case2: formAction を用いた Server Actions

さらに、次の要素だと、 formAction を用いて、 Server Actions が利用できます。

  • <button>
  • <input type="submit" ...>
  • <input type="image" ...>

ReactはformAction属性に未対応でしたが、渡すことができるようになりました。

この場合、 <form action= ...> に Server Actions を書いていても呼ばれません。
こちらより優先されます。

export default async function B() {

// ... (略)

  async function fServerAction(formData: FormData) {
    'use server';

    console.log('--- fServerAction ---');
    console.log(formData);
  }

  async function fServerActionWithFormAction(formData: FormData) {
    'use server';

    console.log('--- fServerActionWithFormAction ---');
    console.log(formData);
  }

  return (
    <>
      {examples.map(
        ({ id, name, pos }: { id: string; name: string; pos: string }) => (
          <p key={id} className="m-5">
            {/* @ts-expect-error Async Server Component */}
            <form action={fServerAction}>
              <input type="text" name="id" defaultValue={id} />
              <input type="text" name="name" defaultValue={name} />
              <input type="text" name="pos" defaultValue={pos} />
              <button
                type="submit"
                className="w-1/12 rounded bg-blue-500 p-2 font-bold text-white hover:bg-blue-700 "
                formAction={fServerActionWithFormAction}
              >
                Mutate
              </button>
            </form>
          </p>
        ),
      )}
    </>
  );
}

Case3: クライアントコンポーネントから Server Actions を呼ぶ

Server Actions は、サーバーコンポーネントだけの機能だと思っていましたが、なんとクライアントコンポーネントからも呼べるようです。

これは本当にいろいろと劇的に変わりそうですね。
まさにコンポーネント単位での クライアント OR サーバー が実現できそうです。

  • Server Actions
    • _action.tsx
'use server';

export async function fServerActionFromAnywhere(formData: FormData) {
  // 'use server'; // 1行目(トップレベル)に宣言しているので不要

  console.log('--- fServerAction ---');
  console.log(formData);
}

export async function fServerActionWithFormActionFromAnywhere(formData: FormData) {
  // 'use server'; // 1行目(トップレベル)に宣言しているので不要

  console.log('--- fServerActionWithFormAction ---');
  console.log(formData);
}
  • クライアントコンポーネント
    • B.client.tsx
'use client';

// ...(略)

import {
  fServerActionFromAnywhere,
  fServerActionWithFormActionFromAnywhere,
} from './_action';

export default function B({ children }: { children: React.ReactNode }) {

  // ...(略)

  const { a, d } = pageState;

  // ...(略)

  return (
    <div>
      <form name="fServerAction" action={fServerActionFromAnywhere}>
        <input type="hidden" name="a" defaultValue={a} />
        <input type="hidden" name="d" defaultValue={d} />
        <button
          type="submit"
        >
          ServerAction
        </button>
      </form>
      <form name="fServerActionWithFormAction">
        <input type="hidden" name="a" defaultValue={a} />
        <input type="hidden" name="d" defaultValue={d} />
        <button
          type="submit"
          formAction={fServerActionWithFormActionFromAnywhere}
        >
          with formAction
        </button>
      </form>
      <div>{children}</div>
    </div>
  );
}

サーバー側で次のログが出ます。

--- fServerAction ---
FormData {
  [Symbol(state)]: [
    { name: 'a', value: '' },
    { name: 'd', value: 'asc' },
    { name: 'd', value: 'asc' }
  ]
}

--- fServerActionWithFormAction ---
FormData {
  [Symbol(state)]: [ { name: 'a', value: '' }, { name: 'd', value: 'asc' } ]
}

Case4: Page({ params }) から値を取得する

ちなみに Server Actions ですが、自動での暗黙的な状態遷移(リロード)を伴うことで、ふたたびサーバー側での処理を行っているようです。
(そのため router.refresh() とセットで使うようなサンプルも見かけます)

その理由ですが、このあたりはRSCの特徴であり、↓あたりに書かれている通りです。

For now, you must re-render the entire React tree from the root server component
今のところ、ルートサーバーコンポーネントからReactツリー全体を再レンダリングする必要があります。
https://www.plasmic.app/blog/how-react-server-components-work

この関係で、 Server Actions は次の例のように、 Page({ params }) から値を取得できます。

  • app/demo/[id]/like-button.tsx
'use client';

// クライアントコンポーネントで、Server Actions の関数を props で受け取って使う
export default function LikeButton({ increment }: { increment: () => void }) {
  return (
    <button
      onClick={async () => {
        await increment();
      }}
    >
      Like
    </button>
  );
}
  • app/demo/[id]/page.tsx
import LikeButton from './like-button';

import type { ReadonlyURLSearchParams } from 'next/navigation';
export type PageProps = {
  params: {};
  searchParams: ReadonlyURLSearchParams & {
    location: string;
  };
};

export default async function Page(pageProps: PageProps) {
  async function increment() { // Server Actions
    'use server';
    console.log(pageProps);
  }

  return (
    <>
      <LikeButton increment={increment} />
    </>
  );
}

次のURLでアクセスし、 画面に表示される Like ボタンをクリックすると、

http://localhost:3000/demo/123?location=456

サーバー側で次のログが出ます。

{ params: { id: '123' }, searchParams: { location: '456' } }

その他: さらなる実験的な機能

実験的: useOptimistic フックを使う

useOptimistic という実験的なフックがあります。
Server Actions 版の <Suspense> のようなイメージです。
Server Actions の非同期関数を呼んだとき、応答が返るまではとりあえず Sending... などと表示できます。

  • Thread.tsx
'use client';

import { experimental_useOptimistic as useOptimistic, useRef } from 'react';
import { send } from './_actions';

export function Thread({
  messages,
}: {
  messages: { message: string; sending: boolean }[];
}) {
  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (state: [unknown], newMessage: string) => [
      ...state,
      { message: newMessage, sending: true },
    ],
  );
  const formRef = useRef();

  return (
    <div>
      {optimisticMessages.map(
        ({ message, sending }: { message: string; sending: boolean }) => (
          <div key={message}>
            {message}
            {sending ? 'Sending...' : ''}
          </div>
        ),
      )}
      <form
        action={async (formData: FormData) => {
          const message = formData.get('message') ?? '';
          formRef.current.reset();
          addOptimisticMessage(message);
          await send(message);
        }}
        ref={formRef}
      >
        <input type="text" name="message" />
      </form>
    </div>
  );
}
  • _actions.tsx
'use server';

export async function send(message: string) {
  'use server';

  console.log('--- send ---');
  console.log(message);
}
  • page.tsx
import { Thread } from './Thread';

// ...(略)

export default async function Page() {

  // ...(略)

  const messages = [{ message: "hoge", sending: true }];

  return (
    <>
      <Thread messages={messages} />
    </>
  );
}

Validation: フォームの値をチェック

Next.js 公式では次のようなやり方が紹介されていますが、

サーバーアクションに渡されたデータを、アクションを起動する前に検証またはサニタイズする例
  • app/_actions.js
"use server";
 
import { withValidate } from "lib/form-validation";
 
export const action = withValidate((data) => {
 ...
});
  • lib/form-validation
export function withValidate(action) {
  return (formData: FormData) => {
    'use server';
 
    const isValidData = verifyData(formData);
 
    if (!isValidData) {
      throw new Error('Invalid input.');
    }
 
    const data = process(formData);
    return action(data);
  };
}

実践的な話ですと、
Case3で紹介した方法で、クライアントコンポーネントから Server Actions を呼ぶやりかたが良いと思います。とりあえずは、 react-hook-form や Zod など、今まで使ってきた Validation 用のライブラリに頼ることになると思います。
(今後は Server Actions のほうで何かしら提供があるのでは?と思っています。まだアルファ版なので)

Validationのサンプルコード

Zodの例

  • Server Actions
    • app/demo/server-actions/serverActionG.tsx
'use server';

// 略

export async function serverActionDBA({
  id,
  name,
  pos,
}: {
  id: string;
  name: string;
  pos: string;
}) {
  
  // ここでDBアクセス
  
  return '{status: 200}';
}

  • サーバーコンポーネント
    • app/demo/B.server.tsx
// 略 ※サーバーコンポーネント

import G from './G';
import { serverActionDBA } from './server-actions/serverActionG';

export default async function B() {

  // 略

  return (
    <ul className="list-disc">
      {rows.map(({ id, name, pos }) => (
        <li key={id} className="m-5">
          <G id={id} name={name} pos={pos} serverActionDBA={serverActionDBA} />
          <Suspense fallback={<div></div>}>
            {/* @ts-expect-error Async Server Component */}
            <F_server id={id} name={name} pos={pos} />
          </Suspense>
        </li>
      ))}
    </ul>
  );
}
  • クライアントコンポーネント
    • app/demo/G.tsx
'use client';

import { useState } from 'react';
import { z } from 'zod';

export default function G({
  id,
  name,
  pos,
  serverActionDBA,
}: {
  id: string;
  name: string;
  pos: string;
  serverActionDBA: ({
    id,
    name,
    pos,
  }: {
    id: string;
    name: string;
    pos: string;
  }) => Promise<string>;
}) {
  const [error, setError] = useState('');

  return (
    <>
      <div>
        <>
          <form
            action={async (formData: FormData) => {
              const ValidationSchema = z.object({
                id: z.string().min(2),
                name: z.string().max(25),
                pos: z.string().max(10),
              });

              try {
                ValidationSchema.parse({
                  id: formData.get('id'),
                  name: formData.get('name'),
                  pos: formData.get('pos'),
                } as z.infer<typeof ValidationSchema>);

                const result = await serverActionDBA({
                  id: formData.get('id')?.toString() ?? '',
                  name: formData.get('name')?.toString() ?? '',
                  pos: formData.get('pos')?.toString() ?? '',
                });

                console.log(result);

                setError('');
              } catch (error) {
                setError('Error');
              }
            }}
          >
            <input
              name="id"
              type="text"
              className="w-1/5 rounded border-gray-200"
              defaultValue={id}
            />
            <input
              name="name"
              type="text"
              className="w-2/5 rounded border-gray-200"
              defaultValue={name}
            />
            <input
              name="pos"
              type="text"
              className="w-1/5 rounded border-gray-200"
              defaultValue={pos}
            />
            <div className="inline w-1/5">
              <button className="w-16 rounded bg-blue-500 p-2 font-bold text-white hover:bg-blue-700">
                Save
              </button>
            </div>
            <p className="text-red-600">{error}</p>
          </form>
        </>
      </div>
    </>
  );
}

このあたりのコードはこちらにあります。

https://github.com/vteacher-online/nrstate-demo/tree/example/next13-boilerplate

FormDataを使わないパターン

Server Actions だけ使うこともできます(FormDataに紐づく処理を空にする)。

'use server';

export async function serverActionEmpty() {}

export async function serverActionDBA({ id, name, pos }: { id: string; name: string; pos: string; }) {
  return `serverActionDBA: id=${id}, name=${name}, pos=${pos}`;
}
{/* Server Components */}
<form action={serverActionEmpty}>
  <G serverActionDBA={serverActionDBA} id={id} name={name} pos={pos} />
</form>
'use client';

<button onClick={ async () => {

    // ここで Validation
    // ...(略)

    // ここで Server Actions
    const result = await serverActionDBA({
        id: _id, name: _name, pos: _pos
    });

    console.log(result); // Server Actions の戻り値を表示
}}
>
  G
</button>

このあたりのコードはこちらにあります。

https://github.com/vteacher-online/nrstate-demo/tree/example/server-actions-validation

  • ※Zodについては、次のようなやり方も話題です。

https://twitter.com/t3dotgg/status/1654178753914732552?s=46&t=eKzj7qtro8ueO6SxVMqLIQ

  • ※ azukiazusa さんのこちらの記事も必読です!

https://azukiazusa.dev/blog/nextjs-server-action/

注意点

TypeScript のほうが追いついておらず、 サーバーコンポーネントが返す非同期関数のPromiseに対してエラーが出るので、 @ts-expect-error で消しておきましょう。

{/* @ts-expect-error Async Server Component */}
  • 公式のアナウンス

https://nextjs.org/docs/app/building-your-application/data-fetching/fetching#asyncawait-in-server-components

さいごに

連日深夜は、 Vercel Ship で大忙しですね。

(ねむい・・・)

Discussion