🤾‍♂️

Server Action と useFormState

2023/12/03に公開

本稿は Next.js で今試せる React canary の機能「useFormState」に関する記事です。Server Action を使用すると、API Client を使用せずにブラウザから直接サーバーサイドの関数を実行できます。Server Action のメリットは以下のものが挙がります。

  • API Client が不要になる
  • ハイドレーションを待たずに反応できる
  • Progressive Enhancement を維持できる

useFormState の話の前に、Server Action について少し触れていきます。

Server Action だけではない「Client Action」とは?

「Server Action」というワードを知っている方は多いと思いますが「Client Action」はまだ馴染みのないワードかもしれません。React、Next.js いずれも公式ドキュメントではまだ明確に区別されて解説されていませんが「Server Action」とは少し異なるものです。出典は以下の Tweet や、各ドキュメントにちらほら現れている程度のものになります。

Server Component が"use client"ディレクティブを境界に Client Component となるように、Client モジュールとして扱われる関数は「Client Action」となります。Next.js 公式ドキュメントの Server Action を Client Component で使用する例をみてみましょう。myAction関数はサーバーのコードであるため、process.env.MY_SECRET_VALUEが参照でき、サーバーログとして出力されます。これは紛れもなく「Server Action」です。

ClientComponent.tsx
"use client";

import { myAction } from "./actions";

export default function ClientComponent() {
  return (
    <form action={myAction}>
      <button type="submit">Add to Cart</button>
    </form>
  );
}
actions.ts
"use server";

export async function myAction() {
  console.log(process.env.MY_SECRET_VALUE);
}

ここでactoins.ts"use server"ディレクティブを"use client"ディレクティブに変更してみましょう。すると今度は、ブラウザにログが出力されるようになります。ただし、出力されるログはundefinedです。

actions.ts
"use client"; // <- 📌

export async function myAction() {
  console.log(process.env.MY_SECRET_VALUE);
}

ディレクティブを変更しただけですが、この 2 つは別物としてみなせます。Client モジュールとして import された後者は、ブラウザ向けにバンドルされるモジュールです。そのため機密情報に相当するprocess.env.MY_SECRET_VALUEはバンドルされないようになっています。これが「Client Action」です。

この例は、わざわざファイルを分割する必要はありません。Client Component で宣言した関数も「Client Action」となります。以下の例は一見「Server Action」のサンプルに見えますが、実は「Client Action」のサンプルだったということです。

ClientComponent.tsx
"use client";

export default function ClientComponent() {
  async function myAction() {
    console.log(process.env.MY_SECRET_VALUE);
  }
  return (
    <form action={myAction}>
      <button type="submit">Add to Cart</button>
    </form>
  );
}

つまり、Server Action として期待してaction属性に渡している関数は、一概に「Server Action」と呼ぶことができません。Next.js の公式ドキュメントでは、この一見同じに見える「Server Actions / Client Actions」を併せて「React Actions」と呼んでいるようです

React Actions (both server and client) support progressive enhancement, using one of two strategies:

本稿ではこれにならい「Server Action / Client Action」を呼び分け、二つの総称を「React Action」と呼ぶ様にします(現時点、公式呼称であるとは言い切れず、便宜上こう呼ぶことを予めご了承ください)。

拡張された form 要素

React Canary では「React Action」の追加に加え、<form>要素に変化が起こっています。従来 URL 文字列しか渡せなかったaction属性には「関数 = React Action」が渡せるようになっています。これは Server Action のサンプルコードでも示されていた周知の情報ですね。

action属性は Server Action に限らず、React Action を渡すことが可能です。極端な話をすると、React Action の中で「外部 API サーバーと通信してデータ更新をする」という処理は必須ではありません。

試しに Client Action でwindow.alertを呼び出してみましょう。これが動作するのは、Server Action ではなく Client Action だと考えれば当然のことです。

MyForm.tsx
"use client";

export function MyForm() {
  function formAction(formData: FormData) {
    window.alert(`Hello ${formData.get("message")}`);
  }
  return (
    <form action={formAction}>
      <input type="hidden" name="message" value={"world"} />
      <button>window.alert</button>
    </form>
  );
}

React Action 向けの React 標準 Hook

React Action を使用するコードは、React Action 向けの React 標準 Hook が使用できます。いずれも Canary の Hook ですが、React Action と連携するための Hook です。

  • useFormStatus
  • useFormState
  • useOptimistic

useFormStatus

useFormStatusHook は Form 送信中の状態を参照できます。先ほどの例を以下に変更し、非同期関数を実行してみます。先ほどと異なりボタンを押下すると、1000ms 経過後にwindow.alertが呼ばれるようになります。1000 ms 経過前は Form の「送信中」にあたるため、ボタンはdisabledになります。

MyForm.tsx
"use client";

import { useFormStatus } from "react-dom";

function Button() {
  // 📌 Form 送信中は pending: true になる
  const { pending } = useFormStatus();
  return <button disabled={pending}>window.alert</button>;
}

export function MyForm() {
  async function formAction(formData: FormData) {
    await new Promise((resolve) => setTimeout(resolve, 1000));
    window.alert(`Hello ${formData.get("message")}`);
  }
  return (
    <form action={formAction}>
      <input type="hidden" name="message" value={"world"} />
      <Button />
    </form>
  );
}

useFormStatusHook は、親(祖先)コンポーネントとして最初の<form>要素の状態を参照します。戻り値pendingは、送信中かどうかの boolean 値で、Form が送信中か否かの判定に使用できます。

useFormState

サンプルを少し変更し、ボタン押下でインクリメントする例を見て見ましょう。useFormStateHook は、第 1 引数に React Action を、第 2 引数に初期値をとります。「0」から始まり、ボタンを押下すると 100ms 遅延してインクリメントされます。先ほど同様に Form の送信中、ボタンはdisabledになります。

MyForm.tsx
"use client";

import { useFormState, useFormStatus } from "react-dom";

function Button() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>icnrement</button>;
}

export function MyForm() {
  async function formAction(prevValue: number) {
    await new Promise((resolve) => setTimeout(resolve, 100));
    return prevValue + 1;
  }
  // 📌 count は React Action の戻り値で更新される
  const [count, formDispatch] = useFormState(formAction, 0);
  return (
    <form action={formDispatch}>
      <p>count: {count}</p>
      <Button />
    </form>
  );
}

Server Action と Client Action の違いは?

ここまで解説したように、React Action 向けの React 標準 Hook は「Server Action / Client Action」を分け隔てることなく使用できます。ただし、Client Action として使用する場合、冒頭で紹介した「Server Action」のメリットを満たすことができません。

  • API Client が不要になる
  • ハイドレーションを待たずに反応できる
  • Progressive Enhancement を維持できる

Server Action を調べると「Progressive Enhancement」というワードをよく目にします。Progressive Enhancement とは「基本的機能を損なわないようにしつつ、JS が有効な環境では最良の体験を提供する」という実装方針です。From の場合、送信ができてバックエンド処理が行えるまでが基本的機能にあたります。ここまで紹介したコードはブラウザで動作していたため、JS をオフにしたら From の基本的機能が動かなくなってしまいます。

action属性に「Server Action」を渡し、ブラウザで行っていた更新処理をサーバーサイドで行うことで、基本的機能を損なうことがありません。クライアントサイドのリアルタイムバリデーション(送信前のバリデーション)などを「最良の体験」の一部として提供することは Progressive Enhancement と呼べます。つまりaction属性に関数を渡しているからといって「Progressive Enhancement を維持できている」とは一概には言えないということになります。

Progressive Enhancement が維持できていない例

実は Progressive Enhancement を維持するのは結構大変です。ドキュメントに書いてあるからといって、提供される Hook や機能を使用すればいつでも維持できるものではありません。

エラーハンドリングとフィードバック表示を例に見ていきましょう。以下のように、Client Action の中で Server Action を呼ぶことが出来ます。この例では Server Action の戻り値をsetStateで保持しエラー表示していますが、Progressive Enhancement は維持できていません。 Client Action をaction属性に渡しているため、JS OFF 環境では動かないのが理由です。同時に、ハイドレーションを待たずに反応できるといった面も損なわれます。

MyForm.tsx
"use client";

import { useState } from "react";
import { serverAction } from "./action";

export function MyForm() {
  const [message, setMessage] = useState<string | null>(null);
  const clientAction = async (formData: FormData) => {
    const res = await serverAction(formData);
    setMessage(res.message);
  };
  return (
    <form action={clientAction}>
      {message && <p>error: {message}</p>}
      <button>push</button>
    </form>
  );
}
action.ts
"use server";

export async function serverAction(formData: FormData) {
  if (Math.random() < 0.5) {
    return { message: "Internal Server Error" };
  }
  return { message: `${new Date().toLocaleTimeString()}` };
}

Progressive Enhancement が維持できている例

Progressive Enhancement を維持しつつ動かすためには、Server Action と useFormState を使用します。JS オフにした場合、ボタン押下で画面が毎回リロードされます。しかし、状態が更新されて表示されることが確認できます。「useFormStateを何故使うのか?」という問いに対しては、Progressive Enhancement を維持するためだと言い切れるでしょう。

MyForm.tsx
"use client";

import { serverAction } from "./action";
import { useFormState } from "react-dom";
import { State, initialState } from "./state";

export function MyForm() {
  const [formState, formDispatch] = useFormState(serverAction, initialState);
  return (
    <form action={formDispatch}>
      {formState.message && <p>error: {formState.message}</p>}
      <button>push me</button>
    </form>
  );
}

action.ts
"use server";

import { State } from "./state";

export async function serverAction(
  _: State,
  formData: FormData
): Promise<State> {
  if (Math.random() < 0.5) {
    return { message: "Internal Server Error" };
  }
  return { message: `${new Date().toLocaleTimeString()}` };
}
state.ts
export type State = {
  message: string | null;
};

export const initialState: State = {
  message: null,
};

ただし、このformStateの更新は、Server Action を通じて更新するしか方法がありません。これまで、JS があたりまえのように有効な環境で実装していたわけですから、Progressive Enhancement を維持するためのテクニックがこれから求められるようになるでしょう。

まとめ

Next.js ドキュメントでは「Server Action」の括りが目立ち、Progressive Enhancement が推奨されています。Server Action が生まれた背景を考えると Client Action にスポットを当てる必要はあまりないと思いますが、知っておく事で、実装方針の検討は捗るはずです。

  • Server Action で括られているコードは、Server Action とは限らない
    • Server Action だと思っていた関数は、実は Client Action だったかもしれない
  • Server Action を使用しているからといって、メリットが最大限活かせるとは限らない
    • Progressive Enhancement を維持するには、工夫が必要
    • 維持するかどうか、はじめに検討する余地あり
  • Cient Action だからといってダメではない
    • むしろ React Action と連携できる React 標準の Hook が活用できる
    • Static Exports した Next.js でも、Client Action 動かせる

個人的に Cient Action は API Client のコードを削減できるという点だけでもメリットがあるので、使える場面では積極的に採用したいなと感じています。

Discussion