💈

Next.js App routerとwindow.history.pushState()で作るShallow routingのデモ

2024/04/27に公開

はじめに

Next.js App routerwindow.history.pushState() で Shallow routing のデモを作ってみました。Shallow routing はページの再読み込みを起こさずにブラウザ履歴を操作しページの状態管理を行えます。

Next.js v14.1 でwindow.history.pushState()window.history.replaceState()が使えるようになり、App router でも Shallow routing が可能になりました。

https://nextjs.org/blog/next-14-1#windowhistorypushstate-and-windowhistoryreplacestate

https://nextjs.org/docs/app/building-your-application/routing/linking-and-navigating#using-the-native-history-api

Pages router ではrouter.push('/?counter=10', undefined, { shallow: true })のようにshallow: trueを指定することで、Shallow routing ができます。

https://nextjs.org/docs/pages/building-your-application/routing/linking-and-navigating#shallow-routing

今回は、Next.js App router とwindow.history.pushState()で Shallow routing のデモを作ってみました。

デモの解説

ソースコードは以下の通りです。

まずpage.jsではクライアントコンポーネントのShallowRoutingDemoを表示するだけです。

page.tsx
import { ShallowRoutingDemo } from "./_components/shallow-routing-demo";

export default function Home() {
  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
      <ShallowRoutingDemo />
    </main>
  );
}

ShallowRoutingDemoコンポーネントでは、useRouterrouter.back()router.forward()を使って、ブラウザの戻る・進むボタンを実装しています。

また、ColorStateProvideruseColorStateを使ってRedBludYellowコンポーネント内のボタンをクリックするとパネルの色が変わるようにしています。

Context APIを強いて使う必要はないですが、経験上、子や孫のコンポーネントで状態(この場合はパネルの色)を変更したい場合が多くあるように感じています。なので、どのコンポーネントからでも状態変更がしやすいようにこのような形にしています。

shallow-routing-demo/index.tsx
"use client";

import { useRouter } from "next/navigation";
import { ColorStateProvider, useColorState } from "./use-color-context";

export function ShallowRoutingDemo() {
  const router = useRouter();

  return (
    <ColorStateProvider>
      <main className="flex min-h-screen flex-col items-center gap-16 p-24">
        <h1 className="text-4xl font-bold">Window History Demo</h1>
        <div className="flex gap-4">
          <button
            className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
            onClick={router.back}
          >
            Go Back
          </button>
          <button
            className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
            onClick={router.forward}
          >
            Go Forward
          </button>
        </div>
        <RedBludYellow />
      </main>
    </ColorStateProvider>
  );
}

function RedBludYellow() {
  const { colorState, setColorState } = useColorState();

  return (
    <div className="flex flex-row space-x-4">
      <button
        className="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"
        onClick={() => setColorState("RED")}
      />
      <button
        className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
        onClick={() => setColorState("BLUE")}
      />
      <button
        className="bg-yellow-500 hover:bg-yellow-700 text-white font-bold py-2 px-4 rounded"
        onClick={() => setColorState("YELLOW")}
      />
      <div
        className={`w-24 h-24 ${
          colorState === "RED"
            ? "bg-red-500"
            : colorState === "BLUE"
            ? "bg-blue-500"
            : "bg-yellow-500"
        }`}
      />
    </div>
  );
}

use-color-context.tsxでは、ColorStateProvideruseColorStateを定義しています。

ColorStateProviderではProviderラッパーの提供だけではなくpopstateイベントのリスナーの登録も行っています。popstateイベントとは、ブラウザの履歴が変更されたときに発生するイベントです。例えば戻る・進むの操作をした場合にイベントが発生します。

use-color-context.tsx
"use client";

import { usePathname } from "next/navigation";
import {
  createContext,
  useContext,
  ReactNode,
  useState,
  useEffect,
} from "react";

type ColorState = "RED" | "BLUE" | "YELLOW";
interface ColorContextType {
  colorState: ColorState;
  setColorState: (colorState: ColorState) => void;
}

const ColorStateContext = createContext<ColorContextType | undefined>(
  undefined
);

export const useColorState = () => {
  const context = useContext(ColorStateContext);
  if (!context) {
    throw new Error("useColorState must be used within a ColorStateProvider");
  }
  return context;
};

export const ColorStateProvider = ({ children }: { children: ReactNode }) => {
  const pathname = usePathname();
  const [colorState, setColorState] = useState<ColorState>("RED");

  useEffect(function setupPopStateListener() {
    const handlePopState = (event: PopStateEvent): void => {
      if (event.state?.colorState) setColorState(event.state.colorState);
    };

    window.addEventListener("popstate", handlePopState);

    return () => {
      window.removeEventListener("popstate", handlePopState);
    };
  }, []);

  useEffect(
    function pushColorStateIfNeeded() {
      const isInitialRender = !window.history.state?.colorState;
      if (isInitialRender) {
        window.history.replaceState({ colorState }, "", pathname);
        return;
      }
      const isBrowserAction = window.history.state?.colorState === colorState;
      if (isBrowserAction) return;
      window.history.pushState({ colorState }, "", pathname);
    },
    [colorState, pathname]
  );

  return (
    <ColorStateContext.Provider value={{ colorState, setColorState }}>
      {children}
    </ColorStateContext.Provider>
  );
};

pushColorStateIfNeeded関数では、初回レンダー時にwindow.history.replaceState()を使って初期状態を設定しています。また、ブラウザ操作(戻る・進む)による状態変更があった場合は履歴に新しいエントリを追加しません。この 2 つの条件で余計なエントリの追加やwindow.history.state.colorStateuseStateの状態に齟齬を生じさせないようにしています。

  useEffect(
    function pushColorStateIfNeeded() {
      const isInitialRender = !window.history.state?.colorState;
      if (isInitialRender) {
        window.history.replaceState({ colorState }, "", pathname);
        return;
      }
      const isBrowserAction = window.history.state?.colorState === colorState;
      if (isBrowserAction) return;
      window.history.pushState({ colorState }, "", pathname);
    },
    [colorState, pathname]
  );

pushState()とreplaceState()について

window.history.pushState()window.history.replaceState()は、ブラウザの履歴を変更するメソッドです。

  • pushState()は履歴に新しいエントリを追加します。
  • replaceState()は現在のエントリを置き換えます。

この 2 つのメソッドは第一引数に状態オブジェクト、第二引数にタイトル、第三引数に URL を取ります。

pushState(state, unused)
pushState(state, unused, url)

今回はstate{ colorState }を渡しています。第二引数は歴史的な経緯で残っているもので省略できません。なので空文字を渡しておくと将来の変更に対して安全のようです。第三引数は URL 文字列を渡します。絶対パス・相対パスのどちらでも指定できますが省略すると現在の URL を使用します。

まとめ

Shallow routing による画面の状態管理を初めて実装してみて、ちょっとややこしい部分があったので記事にまとめてみました。この記事が誰かのお役に立てれば幸いです。

Discussion