📝

Server Actions が Next.js 14 からStableに!古参フロントエンドが消失しないために知っておくこと

2023/10/27に公開

思い出します2年前・・・

https://zenn.dev/koduki/articles/3f5215f2a79843


VTeacher所属のSatokoです。

フロントエンドエンジニアとバックエンドエンジニアを兼任しています。
定番なテクノロジーと少しだけGeekなテクノロジーを組み合わせた選定が好みです🤤

Next.js Conf 、朝まで大忙しでしたね。

(ねむい・・・)

Server Actions の一般的な誤解

まず最初に、SNSで話題になっている Server Actions に関する一般的な誤解についてです。

(1) 生のSQLが書かれているというアンチパターン?🤔

一部のサンプルコードでは、生のSQLを直書きしているところがあります。
しかし、以前のプレゼンテーションでも述べられているように、 React Server Component や Server Actions で、この書き方を推奨しているわけではありません。
あくまで「こんなこともできるようになったよ!」とのポジティブな発表になります。

さらに付け加えると、次のようなコードの場合であっても、Prepareのようなセキュリティ対策が自動的に適用されるので、埋め込みミスのようなSQLインジェクションのリスクは無いようにできています。 プリコンパイルされるSQLコードですので、asc/descなどの構文になり得るところを ${ } で埋め込むことはできません。

  • サーバーコンポーネントでSQLを実行する例
async function doSQL(a: string, d: string) {
  const likeCondition = `%${a}%`;
  if (d === 'desc') {
    const {
      rows,
    }: {
      rows: QueryResultRow &
        {
          id: number;
          no: string;
          name: string;
          pos: string;
        }[];
    } =
      await sql`SELECT * FROM players WHERE no ILIKE ${likeCondition} OR name ILIKE ${likeCondition} OR pos ILIKE ${likeCondition} ORDER BY no desc;`; // descの部分を動的に埋め込むことはできません
    return rows;
  } else {
    // ... 略
  }
}

また、build(トランスパイル)時に use server の記述部分(サーバーコンポーネントはこれに該当)のコードは分離されるので、ブラウザで右クリック→ソースを表示でSQL文がわかってしまうなんてことはないようです。

※現実的には Prisma などと組み合わせて使用することになると思います。

(2) Server Actions のセキュリティは?どこからでもアクセスできるのでは?🤔

もうひとつの誤解は、 Server Actions を含む use server の記述箇所、サーバーコンポーネントなどを外部から呼び出されてしまうという誤解です。

おそらく、APIの一般的なイメージから来る疑念だと思いますが、 Server Actions は Next.js の App Router の機能ですので、 React Server Components (RSC) が前提となります。

以前のZenn記事 でも述べている通り、

Because the server needs to do some of the rendering, the life of a page using RSC always starts at the server, in response to some API call to render a React component. This “root” component is always a server component, which may render other server or client components.

翻訳&要約:

  • RSCは常にサーバーから始まる
    • ルート(木構造の根)となるコンポーネントはサーバーコンポーネントである必要あり
    • ルートサーバーコンポーネントからReactツリー全体を再レンダリングする

RSCは常にサーバーから始まります。RSCのフローは、従来のフロントエンドとバックエンド間のAPI通信ではなく、サーバーサイドレンダリング(例:PHP)の動作をイメージするほうが正確だと思います。 use server を記述したコードを実行するには、必ずサーバーにアクセスする必要があります(部分的ではなく全体。部分的にアクセスしても以前の値が返るだけ)。
ただしPHPなどとは異なり、RSCはとてもSPAらしい挙動をしてくれます。リクエストごとにページロードが発生して白い画面がチラチラすることはありません。Reactが大事にしているSPAのエコシステムの良いところです。

(3) iOSおよびAndroidアプリとの共存はどうするの?🤔

もうひとつの誤解は、 Server Actions によりWebアプリケーションからDBへの直接アクセスが可能となってしまうので、モバイルアプリケーション(iOSおよびAndroidアプリ)とのアーキテクチャの一貫性が崩れてしまうのでは?という点です。

実際の現場では、SQLを直接記述する代わりにAPI(RESTful API や GraphQL WebAPIなど)を介してDBとやりとりする方法が一般的だと思います。

実は以前から、Meta側としては React と GraphQL の組み合わせを推奨していました。
しかし、すべてのプロジェクトで GraphQL が使用できるわけではないことが考慮され、 React Server Component という代替的なアプローチが提案されました(他の理由もありますが)。

そのため、むしろ、既存のAPIが用意されていることを想定しているため、それをそのまま使用すれば良いと思いますし、前述のSQLと同様にサンプルコードが提示されています。

  • サーバーコンポーネントでAPI(REST)を呼び出す例
async function doAPI(a: string, d: string) {
  const result = await fetch(
    `${
      process.env.NEXT_PUBLIC_API
        ? process.env.NEXT_PUBLIC_API
        : 'http://localhost:3000'
    }/api/players`,
    {
      method: 'GET',
      headers: {
        'Content-Type': 'application/json',
      },
      next: { revalidate: 5 },
    },
  );

  const json = await result.json();

  const players = json
    .filter((data: { name: string; pos: string }) => {
      return data.name.includes(a) || data.pos.includes(a);
    })
    .sort((value: { id: string }, target: { id: string }) => {
      if (d === 'asc') {
        return value.id < target.id ? -1 : 1;
      } else {
        return value.id > target.id ? -1 : 1;
      }
    });

  return players;
}

※fetchについても改善が続けられていて、サーバーコンポーネントのasync対応も含め、今は直感的に使いやすいfetchに仕上がっていると思います。

では、本題です!

Next.js Conf

本日(日本時間の深夜〜朝)、 Next.js Conf が開催されました!

https://www.youtube.com/live/8q2q_820Sx4?t=5485

このタイミングで Next.js 14 が発表され、 Server Actions が正式に Stable となりました!

https://twitter.com/nextjs/status/1717596665690091542

やっぱりClerkも普通に活用されていますね。

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

Server Actions とは

今から約半年前、 Vercel Ship の4日目 に Server Actions (α版) が発表されました。

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

SNS上で出回っていた "use server" の謎が解け、良い意味でも悪い意味でも、とにかく反響が大きかったという印象です。

React・Nextが目指す方向性についての考察

Server Actions ですが、批判的な意見もあります。

「(PHPに戻すの?)」

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

α版(Zenn記事)のときから説いている私の説ですが・・・、

ある意味、 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側としては React と GraphQL の組み合わせを推奨していましたが、 すべてのプロジェクトで GraphQL が使用できるわけではないことが考慮され、これを代替する新しいアプローチとしてRSCが提案されたことも理由のひとつです。

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

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

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

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

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

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

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

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

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 14.0.0
  • nrstate (状態管理)
  • Server Actions
  • Zod (バリデーション)

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

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

Documents

試してみる

試すには Next.js 14 をインストールします(最新版はnpmでも公開されました)。

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

# 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} />
    </>
  );
}

ローカル環境で試し、 画面に表示される Like ボタンをクリックすると、

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

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

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

react-hook-form や Zod など、今まで使ってきた Validation 用のライブラリに頼りたい場合もあると思います。

公式では次のようなやり方が紹介されています(Zodの例)。

'use client'

import { experimental_useFormState as useFormState } from 'react-dom'
import { useFormStatus } from 'react-dom'
import { createTodo } from '@/app/actions'

const initialState = {
  message: null,
}

function SubmitButton() {
  const { pending } = useFormStatus()

  return (
    <button type="submit" aria-disabled={pending}>
      Add
    </button>
  )
}

export function AddForm() {
  const [state, formAction] = useFormState(createTodo, initialState)

  return (
    <form action={formAction}>
      <label htmlFor="todo">Enter Task</label>
      <input type="text" id="todo" name="todo" required />
      <SubmitButton />
      <p aria-live="polite" className="sr-only" role="status">
        {state?.message}
      </p>
    </form>
  )
}
'use server'

import { revalidatePath } from 'next/cache'
import { sql } from '@vercel/postgres'
import { z } from 'zod'

// CREATE TABLE todos (
//   id SERIAL PRIMARY KEY,
//   text TEXT NOT NULL
// );

export async function createTodo(prevState: any, formData: FormData) {
  const schema = z.object({
    todo: z.string().min(1),
  })
  const data = schema.parse({
    todo: formData.get('todo'),
  })

  try {
    await sql`
    INSERT INTO todos (text)
    VALUES (${data.todo})
  `

    revalidatePath('/')
    return { message: `Added todo ${data.todo}` }
  } catch (e) {
    return { message: 'Failed to create todo' }
  }
}

export async function deleteTodo(prevState: any, formData: FormData) {
  const schema = z.object({
    id: z.string().min(1),
    todo: z.string().min(1),
  })
  const data = schema.parse({
    id: formData.get('id'),
    todo: formData.get('todo'),
  })

  try {
    await sql`
      DELETE FROM todos
      WHERE id = ${data.id};
    `

    revalidatePath('/')
    return { message: `Deleted todo ${data.todo}` }
  } catch (e) {
    return { message: 'Failed to delete todo' }
  }
}

Case3で紹介した方法で、クライアントコンポーネントから Server Actions を呼ぶやりかたもできます(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

さいごに

今までは S3 + CSR/SSG の組み合わせを考えがちでしたが、今後はサーバー側に処理コストをかけていく流れになってくる(戻ってくる)と思います。

Discussion