React Routerで、戻ったときにスクロール位置を復元する方法

​​React​React Router,TypeScript

React Router のデータモードでは、<ScrollRestoration /> を1つ置くだけで、戻るボタンで前のページに戻ったときに、離れたときのスクロール位置が復元されます。リンクで新しいページへ移ったときは、いちばん上から表示されます。

筆者は検証環境で、60匹の猫が並ぶ一覧と猫のページを作り、一覧を下までスクロールしてから猫のページを開き、戻るボタンで戻ったときの window.scrollY を、ScrollRestoration がある場合とない場合で記録しました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153(Chrome for Testing 153.0.8010.12)です。使ったのは react-router 8.5.0、react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2 です。画面の大きさは 400×700px にしました。データはサーバーの代わりに、300ミリ秒待ってから返す関数で用意しました。esbuild で変換したコード(開発用のビルド)を Chromium で動かし、Playwright で操作しています。

いちばん外側のルートに ScrollRestoration を1つ置く

ScrollRestoration のリファレンスによると、このコンポーネントは、ブラウザが行うスクロール位置の復元を、アプリの中のページ移動でも再現するものです。アプリの中に1つだけ置きます。使えるのはデータモードとフレームワークモードで、<BrowserRouter> を使う宣言的モードでは使えません(モードごとに使える API の一覧)。

データモードでは、createBrowserRouter で作ったルーターのいちばん外側のルートのコンポーネントに置くと、どのページにいても描画されます。検証に使ったコードは次のとおりです(検証用に置き方を切り替える部分は省き、ファイルの置き場所に合わせて import のパスを変えています)。

tsx
// main.tsx
import { createRoot } from 'react-dom/client';
import { createBrowserRouter } from 'react-router';
import { RouterProvider } from 'react-router/dom';
import { CatList, CatPage, Root, catLoader, catsLoader } from './routes';

const router = createBrowserRouter([
  {
    path: '/',
    Component: Root,
    HydrateFallback: () => <p>読み込み中…</p>,
    children: [
      { path: 'cats', loader: catsLoader, Component: CatList },
      { path: 'cats/:catId', loader: catLoader, Component: CatPage },
    ],
  },
]);

createRoot(document.getElementById('root')!).render(<RouterProvider router={router} />);
tsx
// routes.tsx(一部)
import { Link, Outlet, ScrollRestoration, useLoaderData } from 'react-router';
import { fetchCats } from './api';

export function Root() {
  return (
    <div>
      <header>
        <Link to="/cats">一覧へ</Link>
      </header>
      <Outlet />
      <ScrollRestoration />
    </div>
  );
}

export async function catsLoader() {
  return { cats: await fetchCats() };
}

export function CatList() {
  const { cats } = useLoaderData<typeof catsLoader>();
  return (
    <ul>
      {cats.map((cat) => (
        <li key={cat.id} style={{ height: 80 }}>
          <Link to={`/cats/${cat.id}`}>{cat.name}</Link>
        </li>
      ))}
    </ul>
  );
}

フレームワークモードでは、RouterProvider からの移行ガイドにあるように、root.tsx の Layout の中で <Scripts /> の直前に置きます。この記事で試したのはデータモードだけです。

宣言的モードのまま <BrowserRouter> の中に置くと、検証環境では次のエラーになり、画面には何も表示されませんでした。

text
uncaught: useMatches must be used within a data router.  See https://reactrouter.com/en/main/routers/picking-a-router.

置く前と置いた後で、戻ったときの位置を比べる

一覧(高さ約4,850px)を scrollY が 2000 のところまでスクロールし、そこに見えている猫のリンクを押しました。猫のページを開いたら上までスクロールして読み、ブラウザの戻るボタンで一覧へ戻ります。

ScrollRestoration を置いていないときは、まず、猫のページが長いと、上からではなく scrollY が 2000 の位置で開きました。リンクで移動しても、React Router はスクロール位置を変えないためです。

戻ったときの位置は、猫のページの長さで変わりました。猫のページが約2,900px と長いときは、一覧は 2000 の位置に戻りました。猫のページを画面の高さ(700px)まで短くすると、一覧はいちばん上(0)に戻りました。

横棒グラフ。一覧を離れる前は2000pxまでスクロールしていた。ScrollRestoration あり(猫のページが長い・短い どちらも)は2000px、ScrollRestoration なし(猫のページが長いとき)は2000px、ScrollRestoration なし(猫のページが短いとき)は0px(いちばん上)
戻るボタンで一覧に戻ったときの scrollY。ScrollRestoration がないと、猫のページが短いときに位置が失われる

ScrollRestoration を置いていないとき、history.scrollRestoration は auto でした。MDN の説明のとおり、auto では履歴で戻ったときに、React Router ではなくブラウザが位置を戻そうとします。ただし検証環境の Chromium では、上で見たとおり、猫のページが短いと一覧の位置は戻りませんでした。ほかのブラウザでは試していません。

ScrollRestoration を置くと、history.scrollRestoration は manual になりました。ブラウザに任せず、React Router が位置を戻します。猫のページは長さにかかわらず上から開き、戻ったときは、どちらの長さでも一覧が 2000 の位置に戻りました。

位置が戻るのは、ローダーが読み込み終えてから

戻るボタンを押してから、50ミリ秒ごとに、表示されているページと scrollY を記録しました(ScrollRestoration あり、猫のページが長い場合。+455ms と +509ms の2行は省いています)。

text
戻るを押した後:
  + 14ms 表示=詳細 scrollY=0
  + 70ms 表示=詳細 scrollY=0
  +123ms 表示=詳細 scrollY=0
  +180ms 表示=詳細 scrollY=0
  +235ms 表示=詳細 scrollY=0
  +290ms 表示=詳細 scrollY=0
  +345ms 表示=一覧 scrollY=2000
  +400ms 表示=一覧 scrollY=2000
戻った後: /cats scrollY=2000

一覧の catsLoader が読み込みをしている約300ミリ秒のあいだは、猫のページがいちばん上(0)のまま表示されています。一覧が描画されたときに、同時に 2000 の位置まで戻りました。データが届く前の短い一覧で位置を戻そうとして、失敗することはありません。

ポイント

ScrollRestoration は、ローダーの読み込みが終わって新しいページを描画したところで、スクロール位置を戻します。戻ったときに一覧のデータを読み込み直していても、位置は失われませんでした。

再読み込みしても位置が残る

一覧を 1500 の位置までスクロールして、ページを再読み込みしました。再読み込みの後も、一覧は 1500 の位置で表示されました。このとき sessionStorage には、次の値が入っていました。

text
sessionStorage["react-router-scroll-positions"] = {"default":1500}

リファレンスのとおり、位置は sessionStorage の react-router-scroll-positions に保存されます。保存する名前は storageKey で変えられます。default は、位置を覚えるキー(location.key)の値で、URL を直接開いたページのように、履歴に React Router のキーがまだないときに使われます(lib/router/history.ts)。

補足

インストールした React Router 8.5.0 のコードと、GitHub にあるソースコード(lib/dom/lib.tsx)を読むと、保存して戻すのは window.scrollY、つまりページ全体のスクロール位置です。ページの中に作った、スクロールする要素の位置は対象になっていないと読めます。この環境では、そうした要素では試していません。

同じパスなら、リンクで開いたときも位置を戻すには getKey を使う

既定では、位置は履歴の1つ1つ(location.key)ごとに覚えられます。そのため、猫のページの上部にある「一覧へ」のリンクで一覧に移ると、戻るボタンではなく新しい移動なので、一覧はいちばん上(0)から表示されました。

同じパスなら、リンクで開いたときも前の位置に戻したい場合は、getKey で location.pathname を返します。

tsx
<ScrollRestoration getKey={(location) => location.pathname} />

こうすると、「一覧へ」のリンクで移ったときも、一覧は 2000 の位置で表示されました。リファレンスでも、パス名だけを使うと、前に開いたパスへあとから移動したときに位置が戻る、と説明されています。

ページの途中のタブを押したときは preventScrollReset を付ける

ScrollRestoration を置くと、新しい移動ではページがいちばん上に戻ります。クエリ文字列でタブを切り替えるような移動では、いちばん上に戻ると困ることがあります。

検証では、猫のページの途中(上から約1,200px)に、?tab=meals へのリンクを並べました。比べるために、preventScrollReset を付けないリンクも1つ置いています。

tsx
<nav className="tabs">
  <Link to="?tab=profile" preventScrollReset>
    プロフィール
  </Link>
  <Link to="?tab=meals" preventScrollReset>
    ごはん
  </Link>
  <Link to="?tab=meals" className="no-prevent">
    ごはん(preventScrollReset なし)
  </Link>
</nav>

scrollY が 1000 の位置でリンクを押すと、preventScrollReset を付けたリンクでは 1000 のまま、付けていないリンクでは 0 に戻りました。

text
preventScrollReset あり: 押す前 scrollY=1000 → 押した後 /cats/1?tab=meals scrollY=1000
preventScrollReset なし: 押す前 scrollY=1000 → 押した後 /cats/1?tab=meals scrollY=0

Link のリファレンスによると、preventScrollReset が止めるのは、新しい移動でいちばん上に戻す動きだけです。戻るボタンや進むボタンでの位置の復元は、そのまま行われます。useNavigate で移動するときは、useNavigate のリファレンスにあるように、navigate('?tab=meals', { preventScrollReset: true }) と書きます。

​Posted by 86