Next.js → GraphQL みたいな構成で PoC を作成しており、その際に Next.js の新機能である Server Actions を試しました。そのことについて登壇したので振り返りも兼ねてブログを書きます。
Server Components について
まず Server Components について振り返りましょう。Server Components が追加されることで、従来の処理に 2 つの大きな違いが生まれます。
- 処理の開始はサーバで始まる
- React ツリーを生成する際のコンポーネントの扱い
下記のようなコンポーネントをレンダリングしたいとします。これをレンダリングしたいクライアントが、サーバに対して処理をリクエストすることでレンダリングが開始されます。最初に補足ですが、クライアントとサーバのやり取りは実際は JSON で行なっているのに、それを JSX で表記したり、細かい処理の概要までは書いていません。利用者の立場で必要な内容を端折って説明しています。
<div>
<ServerComponent />
<ClientComponent />
</div>
リクエストを受け取ったサーバは React Tree を生成していきます。
このときに Server Components があった場合は props
を渡して対象のコンポーネントを HTML 要素に展開するような処理を行なっていきます。下記コードで行くと、 ServerComponent
が div>p
に展開されて、 ClientComponent
はそのままというイメージです。
<div>
<div>
<p>i'm a server one</p>
</div>
<ClientComponent />
</div>
サーバ側で処理がなされるためライフサイクルフック(useEffect…)、状態フック(useState,,,)、カスタムフックの利用に制限があり、同様にブラウザ上で実行されないので window object へのアクセスができなかったり、ブラウザの API に制限が発生します。
そして、Server Components の変換が終わったら、クライアントに対して生成した React Tree を JSON 形式にして返していきます。 それをクライアント側で受け取り、Client Components の展開を含めて React Tree をクライアント側で生成して、最終的に DOM に変更をコミットします。
<div>
<div>
<p>i'm a server one</p>
</div>
<div/>
<p>i’m a client one</p>
<div>
</div>
つまり中間処理が挟まりますというのが、利用者目線での Server Components の違いであり、そこにメリットと制限が含まれます。主なメリットとしてはサーバ側で処理が完了するため、コンポーネントをレンダリングするにあたって必要なライブラリを、クライアント側にバンドルせずに済むことが挙げられます。
今までの話をまとめると下記のようになります。
- サーバへのリクエストで処理が始まる
- React ツリーを生成する際のコンポーネントの扱いが異なる
- 2 段階に分けて React Tree を生成していく
- hooks や ブラウザ API 呼び出しに制限がかかる
- サーバで処理が完結するのでコンポーネントの依存ライブラリをバンドルせずに済む
Server Actions について
ユーザのインタラクションに対する処理をサーバ側でかつ中間 API を挟まずに実行できる機能です。下記のドキュメントにあるようにクライアントに送信する JavaScript のサイズを削減することができる、つまり Server Components と似た目的を 1 つ持っています。
Server Actions are an alpha feature in Next.js, built on top of React Actions. They enable server-side data mutations, reduced client-side JavaScript, and progressively enhanced forms. They can be defined inside Server Components and/or called from Client Components: https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions
Form Actions
Server Actions を実行する方法は 2 つあり、まず form 要素の action 属性か input 要素の form-action 属性を利用してサーバ側への処理を依頼する方法があります。
use server
ディレクティブを宣言した関数がありますね。この関数をサーバ側で実行するという宣言であり、それを action 属性に渡すことで、submit の実行タイミングで発火されます。form で定義した input の中身は、FormData 型で Server Action 関数に渡されるので、それを処理することで入力値の取得までできます。
export default async () => {
const action = async (data: FormData) => {
"use server";
// do something
};
return (
<form action={action}>
<input name="email" />
<button type="submit">submit</button>
</form>
);
};
一方で、Server Action がエラーを返すと Unhandled Runtime Error
が発生します。これに対する処理は、 error.tsx を同一レイアウト上に定義することで Error Boundary が働き、エラー画面を表示することができます。
Custom invocation
action と form-action を利用しないで Server Action を発火させる方法があり、それが Custom invocation です。Custom invocation の実行は Client Components で利用できるのでさまざまな hooks と合わせて柔軟な表現ができます。
この機能を利用するにはまず、Server Action を定義します。この時に、 use server
ディレクティブを宣言してください。また form action と異なり、引数に自由な値を渡すことができます。
/** action.ts*/
"use server";
export const action = async (id: string) => {
// do something
};
Server Action を定義したら、これを呼び出します。従来の React ライクなままの実装なので非常に感覚として掴みやすいかと思います。
'use client'
import { action } from './action'
export const Favorite = () => (
<button
onClick={async () => {
await action('id')
// do something
}}
>
click
<button>
)
Server Components も Server Actions も分かればコンセプトは非常にわかりやすいかと思います。
with useTransition
Server Actions でリクエストをしつつ、コンポーネントの状態を管理するのに、useTransition を利用していきます。 useTransition では、特定の処理を優先度の低い処理とマークすることで UI のブロッキングを防ぎます。いいねボタンの実装について考えてみます。 ボタンを押下する → ロード状態を挟んで変更が反映されるといったフローを実装するとして、タスクの優先度は低いが、ロード状態を反映させたいケースなどでこれは非常に有用です。
コード自体も解説するところがないほどにシンプルですが、useTransition の返り値を利用して、優先度の低いタスクとマークした上で loading 中(disabled 中)なのかを判断してデザインを切り替える形になります。
import { useTransition } from "react";
import { action } from "./action";
export const Favorite = ({ state }: { state: boolean }) => {
const [isPending, startTransition] = useTransition();
return (
<button
onClick={() =>
startTransition(async () => {
await action(state);
})
}
disabled={isPending}
className="... hover:... disabled:..."
>
<span>I Like This</span>
{isPending ? <Circle /> : <ThumbUp />}
</button>
);
};
with useOptimistic
楽観的更新で即座に更新をかけていきます。つまり、非同期通信の結果を待たずして想定結果を即座に反映させて次の処理に向かわせていきます。
これは React が experimental_useOptimistic
という実験的な hooks を出しているのでそれを useOptimistic
として利用することで実装が可能です。イメージとしては useOptimistic
が楽観的更新のための状態を管理してくれるので、
- 初期値をあてる
- 非同期通信の前に想定結果を optimistic state に書き込む
- 非同期通信のレスポンスが返ってきて props の更新によって状態が更新される
export const Favorite = ({ state }: { state: boolean }) => {
const [optimisticFavorite, addOptimisticFavorite] = useOptimistic(
favorite,
(_, newState: boolean) => newState,
);
return (
<button
onClick={async () => {
addOptimisticFavorite(!favorite);
await mutateFavorite(id, favorite);
}}
>
{/**...*/}
</button>
);
};
他にも experimental_useFormStatus
もあったりします。
Cache/Revalidation
ここまでの内容だと十分利用できそうな気はしますが、先ほどのケースを少し深掘りしてみます。
上記のようなデザインを実装すると、このような流れになると思います。
- 親が一覧 API から記事一覧を取得する
- 子のカードコンポーネントに対して props として個別のデータを渡す
- 記事の id を利用して子のカードコンポーネントはお気に入り API に状態を POST する
また実際にコードに起こすとイメージはこうなります。
export const Articles = async () => {
const articles = await fetchArticles();
return (
<div>
{articles.map((item) => (
<Article key={item.id} {...item} />
))}
</div>
);
};
いいねボタンを押下すると、親の保持している一覧 API のレスポンスと現在の一覧 API のレスポンスが食い違うことになります。 例えば 1 ページ内に、ユーザが押下したいいねの一覧を表示する箇所と先ほどのデザインのように記事一覧にいいねの状態がある場合、いいねの一覧が最新ではないという問題が発生します。 後述しますが、Next.js の fetch はデフォルトでレスポンスをキャッシュしてしまうので、キャッシュと現在の API レスポンスが食い違ってしまう問題も発生します。
先にキャッシュで起こり得る問題を書いていきましたが、Server Actions での更新処理と Next.js のキャッシュ周りについて書いていこうと思います。
Cache
Next.js では fetch 関数を利用した時にデフォルトで GET リクエストのレスポンスをキャッシュしてくれます。ここの挙動は fetch 関数に渡すオプションで制御ができ、cache キーに force-cache
か no-store
を渡すかで挙動が変わります。前者がデフォルトの挙動でキャッシュをしてくれて、後者がキャッシュをせずにリクエストごとに API をコールしてきます。
ほかにも next.revalidate
といったオプションもありますが、今回は省略します。
一方で fetch 関数を利用しない場合 (SDK の利用や、直接 DB を触る Server Actions など)や、POST リクエストでデータの取得を行う GraphQL エンドポイントへのリクエストの場合は fetch でのキャッシュが活用できません。この場合は React が提供する cache 関数を利用して、関数の結果をキャッシュすることができます。
import { cache } from "react";
export const revalidate = 3600; // revalidate the data at most every hour
export const getItem = cache(async (id: string) => {
const item = await db.item.findUnique({ id });
return item;
});
// https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#example
fetch リクエストと関数の実行結果をキャッシュできることはわかりました。最初の親から props を渡す例に戻ると、いいねボタンを押下して更新が終わった後にキャッシュを更新する処理が必要になってきます。Revalidation のために、 revalidatePath
と revalidateTag
が Next.js から提供されているのでこれらについて確認します。
Revalidation
revalidatePath
と revalidateTag
の利用自体は非常に簡単で、処理が必要な箇所で呼び出せば良いです。
import { revalidatePath, revalidateTag } from "next/cache";
async function fetchData() {
"use server";
await fetch(/** */);
revalidatePath("/path/to/[slug]");
revalidateTag("article");
}
revalidatePath
は /articles/[id]
といった Next.js で定義した形でのパスを引数に渡すことで、URL のパスレベルでキャッシュを更新してくれます。
revalidateTag
は fetch 関数のオプションで付与したタグに紐づかれたキャッシュを全て更新します。下記のような関数をイメージしていれば問題ありません。
async function fetchArticles() {
const res = await fetch("https://example.com/articles", {
next: { tags: ["articles"] },
});
// revalidate tags
revalidateTag("articles");
}
ただし、revalidateTag
は fetch 関数のオプションにはありますが cache 関数にはオプションでないため、fetch 関数のみで利用できるオプションになります。next/cache
パッケージで unstable_cache
というものが用意されており、tags を渡せそうなので将来的には状況が変わるかもしれません。
さいごに
- Server Components と Server Actions でバンドルサイズを削減が狙える
- さまざま組み合わせることでインタラクションも柔軟に対応できる
- API のデータキャッシュまわりで大きな制約がある状況
- そもそも unstable であること、そして制約があることを意識して技術選定をする
- 私は unstable でも問題がなく、fetch 中心に設計できそうなら採択しても良いかもという温度感