📸

[Next.js] ServerActionsでフラッシュメッセージを出したい

2025/03/01に公開

やりたいこと

この画像のように、Server Actions でなにかしらの処理が完了したときとかに画面上にフラッシュメッセージを表示したいケースを Next.js(App Router)でやってみます。

概要

やり方をざっくり説明すると、サーバー側で Cookie にメッセージをセットし、クライアント側で useSyncExternalStore を使って Cookie を監視しつつ変化があればトースターを発火するといった流れです。

引数でフラッシュメッセージの UI に表示する値(タイトル、メッセージ、フラッシュメッセージの種類等)を受け取り、JSON 文字列に変換し、Next.js の cookies 関数を使ってセットしていきます。
とりあえず本記事では title と description を受け取るとします。

"use server";

import { cookies } from "next/headers";

export async function flash(content: { title: string; description?: string }) {
  const parsedFlashContent = JSON.stringify({
    ...content,
    key: crypto.randomUUID(),
  });

  (await cookies()).set("flash", parsedFlashContent, { maxAge: 1 });
}

これを ServerActions 内で呼び出せばフラッシュメッセージが表示されるようにしていきます。

Cookie の状態を監視するカスタムフックを作ります。
キーとなるのは useSyncExternalStore という React のビルトインフックです。
https://ja.react.dev/reference/react/useSyncExternalStore

Cookie は React の外側のストアであり、そのストアの状態を監視したいのでまさしくこのフックの使い道かと思います。

import { useSyncExternalStore } from "react";

function subscribe(callback: () => void) {
  const observer = new MutationObserver(callback);
  observer.observe(document, {
    subtree: true,
    childList: true,
    attributes: true,
  });
  return () => observer.disconnect();
}

function getServerSideSnapshot() {
  return undefined;
}

function getCookieValue(name: string) {
  const cookies = document.cookie
    .split("; ")
    .reduce<Record<string, string>>((acc, cookie) => {
      const [key, value] = cookie.split("=");
      acc[key] = value;
      return acc;
    }, {});
  return cookies[name] || "";
}

export function useCookie(cookieName: string) {
  return useSyncExternalStore(
    subscribe,
    () => getCookieValue(cookieName),
    getServerSideSnapshot
  );
}

まずは useSyncExternalStore に必要な外部ストアのサブスクライブ関数ですが、MutationOberver というクラスを使って document を監視します。
document に変化があれば getCookieValue が発火するという流れですね。

次にその getCookieValue ですが、document.cookie は全ての Cookie をとってくるのと、Cookie は";"で区切られている文字列であるため、split(";")で Cookie を配列にしつつ、reduce で key と value の形に変換し、扱いやすくしておきます。

ちなみにサーバー側では Cookie を取得できないため getServerSideSnapshot では undefined を返すとします。

これらを useSyncExternalStore に渡し、Cookie を監視する useCookie を作ります。

useFlash

サーバー側では"flash"というキーでフラッシュメッセージの値をセットしているので、useCookie に"flash"という文字列を渡して Cookie からフラッシュメッセージを取り出しましょう。

import { useCookie } from "@/hooks/use-cookie";
import { z } from "zod";

const flashContentSchema = z.object({
  title: z.string(),
  description: z.string().optional(),
  key: z.string(),
});

export type FlashContent = z.infer<typeof flashContentSchema>;

export function useFlash() {
  const cookieValueJson = useCookie("flash");
  if (!cookieValueJson) return undefined;
  const decodedStr = decodeURIComponent(cookieValueJson);
  const parsedCookieValue = JSON.parse(decodedStr);
  const { data } = flashContentSchema.safeParse(parsedCookieValue);
  return data;
}

useCookie に"flash"を渡し、"flash"というキーの Cookie の値を監視します。
また、サーバー側で JSON 文字列にして Cookie にセットしているので、ここでパースしてオブジェクトに戻す必要があります。
ただ、Cookie に JSON をセットした時点でその JSON 文字列は URL エンコードされるので、先に decodeURIComponent でデコードした後に JSON パースします。

フラッシュメッセージを発火するコンポーネントを作る

先ほど作った useFlash を呼び出し、Cookie にセットされるフラッシュメッセージを取得します。
useEffect 内で、フラッシュメッセージがあればトースターを発火する処理を書きます。
※トースターは shadcn ui の API を使っています。

"use client";

import { useFlash } from "@/hooks/use-flash";
import { toast } from "@/hooks/use-toast";
import { useEffect } from "react";

export function FlashToaster() {
  const flashContent = useFlash();

  useEffect(() => {
    if (flashContent?.key) {
      toast({
        title: flashContent.title,
        description: flashContent.description,
      });
    }
  }, [flashContent?.title, flashContent?.key, flashContent?.description]);
  return null;
}

これをルートの layout.tsx に配置します。

import { FlashToaster } from "@/components/functional/FlashToaster";
import { Toaster } from "@/components/ui/toaster";
import type { ReactNode } from "react";

export default function Layout({ children }: { children: ReactNode }) {
  return (
    <html lang="ja">
      <body>
        <main>{children}</main>
        <FlashToaster />
        <Toaster />
      </body>
    </html>
  );
}

これで実装は終わりです。
あとは下記のように ServerActions 内でフラッシュを発火すればトースターが表示されます。

await flash({ title: "予定が更新されました!" });

別解?

実は元々 useSyncExternalStore の代わりに、Next.js の cookie メソッドを使って Cookie を取得するやり方を考えてました。
それでも発火するにはするのですが、Next.js の cookie を使うとそのルートは Dynamic Rendering になってしまうんですよね。ルートの layout.tsx に cookie の処理を含んだコンポーネントを置いてしまうと結果的に全ルートが Dynamic Rendering になってしまい、Static Rendering や PPR といった選択肢が消えてしまううえ、Cookie を取得し、SSR が完了するまで画面が表示されないため全体的にページ遷移がもっさりして UX が悪くなってしまいます。
結果、クライアントコンポーネントでの Cookie 監視という選択に至りました。
他に良い方法がございましたらコメントいただけますと幸いです。

GitHubで編集を提案

Discussion