ブラウザバックで壊れないstate管理を実現する`location-state`

2023/09/27に公開1

この記事は最近リリースしたlocation-stateというライブラリの紹介記事です。

モチベーション

Reactのstate管理は、様々な分類が可能です。筆者が過去に書いた記事「スコープとライフタイムで考えるReact State再考」では、stateの分類は大きく以下2つの観点で分類ができると述べました。

  • スコープによる分類
  • ライフタイム(=stateの生存期間)による分類

詳しく知りたい方はこの記事を読んでいただきたいのですが、今でもstate管理というと多くの場合スコープによる分類の話が多く、ライフタイムによる分類の話はあまり聞かない気がします。

なぜライフタイム観点が重要か

ライフタイムを意識せずに実装した場合に発生するのが、遷移時に状態が破棄され復元されない、つまりブラウザバックでstateが壊れるという問題です。この問題については以下の記事で、Vercelの社長が2014年にはすでに「historyを壊すべきじゃない」と提唱しています。

https://yosuke-furukawa.hatenablog.com/entry/2014/11/14/141415

残念ながら現在でもこの問題について対応されているサイトは少ないのが実情で、時折ユーザー視点でも問題提起がされています。MPAの場合、BFCacheの普及等によりブラウザによってDomやデータの復元が行われることがあるため、この問題は特にSPAにおいて顕著です。

この問題を解決すべく作られたのがlocation-stateです。

recoil-sync-nextとの棲み分け

余談ですが、実はこの問題に対する取り組みとして筆者は過去にrecoil-sync-nextというライブラリ開発にも携わらせていただきました。このことについても、「Next.jsで戻る厨を満たすrecoil-sync-next」という記事で紹介させていただきました。

上記執筆後、recoilの一部APIとNext.jsを併用した時にメモリリークが発生しうるこがわかりました。recoil開発チームは当時からメモリリーク系の問題を抱えていることは認識しており、対策としてガベージコレクション機能の実装が実験的に行われていたため、筆者は将来的なアップデートで解決するだろうと考えていました。しかしその後、Metaのレイオフの影響なのかrecoil自体のメンテが滞り気味になってしまい、待たれていたガベージコレクションの実装が進捗しない=解決が待たれていたこのメモリリーク問題も解消されず、recoil・recoil-syncに依存した実装のままだと厳しいという気持ちが生まれたことで、本ライブラリの開発に至りました。

location-state

閑話休題。話は戻り、location-stateの紹介です。

location-stateは履歴位置に同期してstateを管理することができるライブラリです。現時点ではNext.jsをメインにテスト・サポートしており、App RouterPages Routerの両方で利用できます。location-stateはScopedパッケージで開発されており、以下のようなパッケージ構成になっています。

  • @location-state/core: location-stateのコア機能を提供するパッケージ
  • @location-state/next: Next.jsのPages Routerで利用するためのパッケージ

@location-state/coreNavigation Apiで同期させることができるため、基本的にフレームワークを選ばず単体利用することが可能です。一方で@location-state/nextはPages Router専用のパッケージで、Navigation Apiではなくrouter.eventsを利用して同期しています。詳細については後述します。

要望が多ければ他フレームワークの専用パッケージも開発するかもしれませんが、当面はNext.jsを中心にサポートする予定です。

Next.js App Routerでの使い方

Next.js App Routerで利用するには@location-state/coreのみOKです。

npm install @location-state/core
# or
yarn add @location-state/core
# or
pnpm add @location-state/core

@location-state/coreuseLocationStateをはじめいくつかのhooksを提供しています。これを利用することで、履歴位置に同期してstateを管理することができます。

このhooksを利用するには、LocationStateProviderを上位のコンポーネントで呼び出す必要があります。以下はlayout.tsxをServer Componentのままにして、Providers.tsxにClient Componentを切り出した例です。

// src/app/Providers.tsx
"use client";

import { LocationStateProvider } from "@location-state/core";

export function Providers({ children }: { children: React.ReactNode }) {
  return <LocationStateProvider>{children}</LocationStateProvider>;
}
// src/app/layout.tsx
import { Providers } from "./Providers";

// ...snip...

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

あとは利用したい箇所でuseLocationStateを呼び出すだけです。

// src/components/Counter.tsx
"use client";

import { useLocationState } from "@location-state/core";

export function Counter() {
  const [counter, setCounter] = useLocationState({
    name: "counter",
    defaultValue: 0,
    storeName: "session",
  });

  return (
    <div>
      <p>
        storeName: <b>{storeName}</b>, counter: <b>{counter}</b>
      </p>
      <button onClick={() => setCounter(counter + 1)}>increment</button>
    </div>
  );
}

現時点でuseLocationStateの引数で渡せるオプションは以下の4つです。

  • name: stateを一意に判別する名前
  • defaultValue: stateのデフォルト値
  • storeName: stateの保存先。sessionurlの2つが利用可能(カスタマイズ可能)
  • refine?: state復元時に検証・変換する関数。undefinedを返すとデフォルト値となる

@location-state/coreの注意点

App Routerでは、履歴を一意に特定するkeyは公開されていません。これについては過去の記事でも何度か触れていますが、Next.jsに提案してみたものの進展がない状況です。

https://github.com/vercel/next.js/discussions/47242

そのため@location-state/coreはデフォルトではNavigation Apiを利用することで履歴を一意に特定する方法をとっています。しかしこのNavigation APIもSafariやFirefoxでまだ実装されていません

そこで、location-stateではChrome以外でも動作するように、@location-state/core/unsafe-navigationというAPIを提供しています。

// src/app/Providers.tsx
"use client";

import { LocationStateProvider, NavigationSyncer } from "@location-state/core";
import { unsafeNavigation } from "@location-state/core/unsafe-navigation";

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <LocationStateProvider syncer={new NavigationSyncer(unsafeNavigation)}>
      {children}
    </LocationStateProvider>
  );
}

これはNavigation APIの挙動を部分的にサポートしたpolyfill的なものですが、実装範囲は必要最小限でライブラリとして積極的なテスト・サポートをしているわけではありません。一応自身のSafariやFirefoxで動作することは確認していますが、利用する方は各々でサポートしたいブラウザ環境でのテストを実施することをお勧めします。

Next.js Pages Routerでの使い方

一方でPages Routerの場合、@location-state/coreに加えて@location-state/nextを利用すれば、Navigation APIに依存せずにuseLocationStateを利用できます。

npm install @location-state/core @location-state/next
# or
yarn add @location-state/core @location-state/next
# or
pnpm add @location-state/core @location-state/next

Pages Routerで利用する際の実装上の違いは、Providerの設定が違うのみです。@location-state/nextからuseNextPagesSyncersyncer(本ライブラリにおいて遷移周りのハンドリングを担うもの)を取得し、Providerに渡しています。

// src/pages/_app.tsx
import { LocationStateProvider } from "@location-state/core";
import { useNextPagesSyncer } from "@location-state/next";
import type { AppProps } from "next/app";

export default function MyApp({ Component, pageProps }: AppProps) {
  const syncer = useNextPagesSyncer();
  return (
    <LocationStateProvider syncer={syncer}>
      <Component {...pageProps} />
    </LocationStateProvider>
  );
}

hooksについてはApp Routerで利用する際と違いはありません。

location-stateの今後

まだ一部ドキュメントが不足しているので拡充していこうと思っています。またよくある実装ユースケースとして、react-hook-formとの併用が考えられます。こちらについても簡単に統合できるようなパッケージの開発を進めていこうと考えています。

感想

今回Scopedパッケージだったこともあり、monorepoでのライブラリ開発の知見も多く得られました。この辺りは後日また記事にまとめてみようかと思っています。

また、今回はrecoil-sync-nextと比較するとrecoilやrecoil-syncのような依存がない分、軽量かつより採用しやすいライブラリになったんじゃないかと思っています。

この記事を通してSPA遷移時に状態が破棄され復元されない問題への問題意識が高まり、また、その解決に本ライブラリが役に立てばとても嬉しく思います。

Discussion