Next.js App routerとwindow.history.pushState()で作るShallow routingのデモ
はじめに
Next.js App router と window.history.pushState() で Shallow routing のデモを作ってみました。Shallow routing はページの再読み込みを起こさずにブラウザ履歴を操作しページの状態管理を行えます。
Next.js v14.1 でwindow.history.pushState()
とwindow.history.replaceState()
が使えるようになり、App router でも Shallow routing が可能になりました。
Pages router ではrouter.push('/?counter=10', undefined, { shallow: true })
のようにshallow: true
を指定することで、Shallow routing ができます。
今回は、Next.js App router とwindow.history.pushState()
で Shallow routing のデモを作ってみました。
デモの解説
ソースコードは以下の通りです。
まずpage.js
ではクライアントコンポーネントのShallowRoutingDemo
を表示するだけです。
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
コンポーネントでは、useRouter
でrouter.back()
とrouter.forward()
を使って、ブラウザの戻る・進むボタンを実装しています。
また、ColorStateProvider
とuseColorState
を使ってRedBludYellow
コンポーネント内のボタンをクリックするとパネルの色が変わるようにしています。
Context APIを強いて使う必要はないですが、経験上、子や孫のコンポーネントで状態(この場合はパネルの色)を変更したい場合が多くあるように感じています。なので、どのコンポーネントからでも状態変更がしやすいようにこのような形にしています。
"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
では、ColorStateProvider
とuseColorState
を定義しています。
ColorStateProvider
ではProvider
ラッパーの提供だけではなくpopstate
イベントのリスナーの登録も行っています。popstate
イベントとは、ブラウザの履歴が変更されたときに発生するイベントです。例えば戻る・進むの操作をした場合にイベントが発生します。
"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.colorState
とuseState
の状態に齟齬を生じさせないようにしています。
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