💬

React Router7で実装するフラッシュメッセージ

2025/01/15に公開

先日のReact Router7のリリースに伴うRemix → RRの乗り換え作業をしていて、フラッシュメッセージ部分をリファクタリングも含めて再実装しました。
https://remix.run/blog/react-router-v7

実装を調べている中で、RRがクライアントの機能のみを提供していた時代のHistory APIのstateを使った方法が多かったため、今回おこなった実装を記事にします。
この記事では主にフルスタックRRなプロジェクトに、アプリケーション全体でフラッシュメッセージを表示させる実装について紹介します。

フラッシュメッセージとはこういうものです。

RR7の基本的なフラッシュメッセージの操作

実装の前にRR7でのフラッシュメッセージの基本的な操作を紹介します。

セッションストレージの作成

まずはフラッシュメッセージを保存するセッションストレージを作成します。

export const sessionStorage = createCookieSessionStorage<FlashMessage>({
  cookie: {
    name: "flash_message_session",
    sameSite: "lax",
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
  },
});

セッションストレージを作成する関数はいくつかあり、保存先を選ぶことができます。
https://reactrouter.com/explanation/sessions-and-cookies#additional-session-utils

  • isSession
  • createMemorySessionStorage
  • createSession (custom storage)
  • createFileSessionStorage (node)
  • createWorkersKVSessionStorage (Cloudflare Workers)
  • createArcTableSessionStorage (architect, Amazon DynamoDB)

フラッシュメッセージの書き込み

フラッシュメッセージはsession.flash()によって書き込みを行います。
https://remix.run/docs/en/main/utils/sessions#sessionflashkey-value
session.flash()によって書き込まれたセッションデータは一度読み取ると破棄されるため、フラッシュメッセージやフォームのバリデーションエラーの表示に便利です。

export async function loader({ request }: Route.LoaderArgs) {
  // セッションストレージからセッションの取得
  const session = await sessionStorage.getSession(
    request.headers.get("Cookie"),
  );
  // フラッシュメッセージの書き込み
  session.flash("flash_message", {
    color: "success",
    message: "success!!",
  });
  // 書き込み後のセッションに更新
  return redirect("/", {
    headers: { "Set-Cookie": await sessionStorage.commitSession(session) },
  });
}

RR7ではjsonの廃止などレスポンス周りの仕様に変更がありますが、redirectは以前のまま第二引数にヘッダーを渡すことができます。

フラッシュメッセージの読み取り

読み取りは以下のように行います。

export async function loader({ request }: Route.LoaderArgs) {
  // セッションストレージからセッションの取得
  const session = await sessionStorage.getSession(
    request.headers.get("Cookie"),
  );
  return data(
    {
      // フラッシュメッセージの読み取り
      flashMessage: session.get("flash_message"),
    },
    {
      // 読み取り後のセッションに更新
      headers: { "Set-Cookie": await sessionStorage.commitSession(session) },
    },
  );
}

export default function Page({ loaderData }: Route.ComponentProps) {
  if(loaderData.flashMessage) {
    return <Alert title={loaderData.flashMessage} />
  }
}

session.get()でほしいセッションのキーを指定して取得することができます。これをページコンポーネントに渡して表示します。

セッションにフラッシュメッセージがある状態でこのルートにアクセスするとloaderから渡されたメッセージが表示されます。その後リロードすると、フラッシュメッセージが一度読み取られたため消えます。

RR7の新機能について

loaderなどのルートモジュールから同じルートのページコンポーネントへデータを渡すには以前はjson()を使用していましたが廃止されました。RR7では素のオブジェクトを返します。

export async function loader({ request }: Route.LoaderArgs) {
  return {
    message: 'sample message'
  }
}

しかし、これではレスポンスヘッダーを操作することができません。レスポンスにヘッダーを設定するには、新しく追加されたdataを使用します。このような意図で返り値がdataになっています。
https://reactrouter.com/how-to/headers#1-wrap-your-return-value-in-data

また、各ルートモジュールの型付けにはRR7で追加されたRouteを使用します。これはルート毎に各ルートモジュールの型を生成してくれたものです。例えばloaderの返り値から、ページコンポーネントで受け取れる引数の型などが生成されます。
これによりdataでレスポンスヘッダーを設定しつつ、bodyのみの型を簡単につけることができます。
Routeは以下のようにimportします。

import type { Route } from "./+types/_index";

実装する

フラッシュメッセージの基本的な操作がわかったのでプロジェクトに導入していきます。

処理をまとめる

先ほどのコードを、書き込み、読み取りのたびにプロジェクトの各所で書くのは冗長なのでクラスにしてみます。

class FlashMessage<T extends Record<string, unknown>> {
  private sessionKey: keyof T & string;
  public sessionStorage: SessionStorage<T>;

  // constructorでフラッシュメッセージのデータの読み書きに使用するキーを受け取る
  constructor(sessionKey: keyof T & string) {
    this.sessionKey = sessionKey;
    // コンストラクタでセッションストレージの作成
    this.sessionStorage = createCookieSessionStorage<T>({
      cookie: {
        name: "flash_message_session",
        sameSite: "lax",
        httpOnly: true,
        secure: process.env.NODE_ENV === "production",
      },
    });
  }

  // 読み取り
  public async get({ request }: { request: Request }): Promise<{
    data:
      | (keyof T & string extends keyof T ? T[keyof T & string] : undefined)
      | undefined;
    cookie: string;
  }> {
    const session = await this.sessionStorage.getSession(
      request.headers.get("Cookie"),
    );
    return {
      data: session.get(this.sessionKey),
      cookie: await this.commit(session),
    };
  }

  // 書き込み
  public async set({
    request,
    data,
  }: { request: Request; data: T[keyof T & string] }): Promise<{
    cookie: string;
  }> {
    const session = await this.sessionStorage.getSession(
      request.headers.get("Cookie"),
    );
    session.flash(this.sessionKey, data);
    return { cookie: await this.commit(session) };
  }

  private commit(session: Session<T>): Promise<string> {
    return this.sessionStorage.commitSession(session);
  }
}

コンストラクタでは、セッションストレージにフラッシュメッセージを保存・取得するためのキーを受け取り、それを基に読み取り・書き込みを行うメソッドを提供します。
各メソッドでは、sessionStorage.commitSession()を実行した後のcookieを返り値に含むので、これをレスポンスヘッダーに設定します。

セッションストレージは今回createCookieSessionStorageで作成し、セッションデータをcookieに保存します。

ではこちらのクラスをインスタンス化します。
例えば以下のように使用します。

const FLASH_MESSAGE_SESSION_KEY = "flash_message";

export const flashMessage = new FlashMessage<{
  [FLASH_MESSAGE_SESSION_KEY]: {
    color?: AlertProps["color"];
    message: string;
  };
}>(FLASH_MESSAGE_SESSION_KEY);

インスタンスのメソッドの使う側は以下のようになります。

読み取り
const { data, cookie } = await flashMessage.get({ request });
書き込み
const { cookie } = await flashMessage.set({
  request,
  data: { color: "success", message: "success!!" },
});

そのまま行うより直感的になったかと思います。

プロジェクト全体の表示設定

プロジェクト全体でフラッシュメッセージを表示させるため、root.tsxに処理を追加します。

root.tsx
export async function loader({ request }: Route.LoaderArgs) {
  const { data: flashMessageData, cookie } = await flashMessage.get({
    request,
  });
  return data(
    {
      flashMessage: flashMessageData,
    },
    {
      headers: { "Set-Cookie": cookie },
    },
  );
}export default function App({ loaderData }: Route.ComponentProps) {
  return (
    <>
      {loaderData.flashMessage && (
        <Alert
          color={loaderData.flashMessage.color}
          title={loaderData.flashMessage.message}
        />
      )}
      <Outlet />
    </>
  );
}

サーバーではgetメソッドで取得したフラッシュメッセージをレスポンスし、Set-Cookieヘッダーに読み取り後のセッションデータを設定します。
ブラウザではサーバーから受け取ったフラッシュメッセージが存在していれば表示します。

これでプロジェクト全体の設定はできたのでフラッシュメッセージを書き込んでみます。リクエストしたらフラッシュメッセージを付与するだけのルートを作ります。
今回はファイルベースのルーティングを使って、routes/flash-message.success.tsxを作成します。

routes/flash-message.success.tsx
export async function loader({ request }: Route.LoaderArgs) {
  const { cookie } = await flashMessage.set({
    request,
    data: {
      color: "success",
      message: "success!!",
    },
  });
  return redirect("/", {
    headers: { "Set-Cookie": cookie },
  });
}

/flash-message/successにアクセスすると/にリダイレクトされ、フラッシュメッセージが表示されます。
その後、リロードするとメッセージが消えます。ちゃんと一度読み取ったフラッシュメッセージのセッションが破棄されています。

動きをつける

メッセージを表示しっぱなしで使うことは少ないと思うので出現してから一定時間経過すると消える動きも追加してみます。

export default function FlashMessage(props: AlertProps) {
  const [isTimeOut, setIsTimeOut] = useState(false);
  useEffect(() => {
    setIsTimeOut(true);
    const timeoutId = setTimeout(() => {
      setIsTimeOut(false);
    }, 2000);
    return () => {
      clearTimeout(timeoutId);
    };
  }, []);

  return (
    <Alert
      {...props}
      classNames={{
        base: `z-50 absolute left-1/2 -translate-x-1/2 duration-700 ease-in-out w-full ${isTimeOut ? "top-0" : "-top-[50vh]"}`,
      }}
    />
  );
}

Hero UIのAlertをラップし、マウントされたら画面上からメッセージが降りてきて、2秒後に画面上に消えるようにしました。

フラッシュメッセージ以外のセッションも書き込みたい場合

同じルートモジュール内で複数のセッションを書き換えたいケースは割とあると思います。
例えば下記の2つの処理を同じレスポンスで行いたい場合です。

  1. ログアウト処理
  2. ログアウト完了を通知するフラッシュメッセージの書き込み

このようなケースではheaders.appendを使用することで、複数のSet-Cookieヘッダーを設定できます。
https://developer.mozilla.org/ja/docs/Web/API/Headers/append

export async function action({ request }: ActionFunctionArgs) {
  const headers = new Headers();
  const authSession = await authSessionStorage.getSession(request.headers.get("cookie"));
  const { cookie } = await flashMessage.set({
    request,
    data: {
      message: "ログアウトしました。",
    },
  });
  // ログアウトに必要なSet-Cookieの設定
  headers.set("Set-Cookie", await authSessionStorage.destroySession(authSession));
  // フラッシュメッセージに必要なSet-Cookieの設定を追加
  headers.append("Set-Cookie", cookie);
  return redirect("/", { headers });
}
親ルートのヘッダーとの結合

全体でレスポンスの設定を行ったことによって親のルートで設定されたヘッダーとの結合が必要なケースがあります。本記事では触れませんが公式で結合方法が説明されているので参考にしてみてください。
https://reactrouter.com/how-to/headers#appending

フラッシュメッセージが連続で表示できない問題

基本的な実装はしましたが、同じページで連続してフラッシュメッセージを表示する場合に2度目以降、メッセージが表示されない問題があります。
これはフラッシュメッセージが変更されてもコンポーネントがアンマウントされずuseEffectが実行されないためです。メッセージの内容が変わってもisTimeOutの値がfalseなので、常にメッセージが画面外に表示されます。さらに、エラーメッセージではフラッシュメッセージのデータが全く一緒でも連続して表示させたい場合もあります。
いくつか解決方法は考えられますが、コンポーネントにkeyを設定する方法が綺麗だったので紹介します。

loaderでセッションからフラッシュメッセージを取得している箇所で、レスポンス時に一意のキーを追加します。

root.tsx
export async function loader({ request }: Route.LoaderArgs) {
  const { data: flashMessageData, cookie } = await flashMessage.get({
    request,
  });
  return data(
    {
-      flashMessage: flashMessageData,
+      flashMessage: flashMessageData
+        ? { ...flashMessageData, key: Date.now() }
+        : undefined,
    },
    {
      headers: { "Set-Cookie": cookie },
    },
  );
}
…

ここでは現在時刻をキーにしています。
続いてコンポーネント側にkeyを渡します。

root.tsx
…
export default function App({ loaderData }: Route.ComponentProps) {
  return (
    <>
      {loaderData.flashMessage?.message && (
        <FlashMessage
          color={loaderData.flashMessage.color}
          title={loaderData.flashMessage.message}
+         key={loaderData.flashMessage.key}
        />
      )}
      <Outlet />
    </>
  );
}

これにより、フラッシュメッセージごとに別コンポーネントとしてマウントされます。
連続でフラッシュメッセージを付与するリンクを踏んでも、毎回上部からメッセージが出現されるようになりました。

まとめ

フラッシュメッセージはサーバーサイドからのメッセージをユーザーにしめすことができ、フォーム送信後の通知やエラーメッセージ表示など、幅広いシナリオで活用できます。
本記事で作成したFlashMessageのクラスを少し抽象度を上げてnpmモジュール化してみたので使ってくださると嬉しいです!
https://www.npmjs.com/package/react-router-flash-message

chot Inc. tech blog

Discussion