React Routerで、戻ったときにスクロール位置を復元する方法
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 のパスを変えています)。
// 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} />);
// 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> の中に置くと、検証環境では次のエラーになり、画面には何も表示されませんでした。
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)に戻りました。

ScrollRestoration を置いていないとき、history.scrollRestoration は auto でした。MDN の説明のとおり、auto では履歴で戻ったときに、React Router ではなくブラウザが位置を戻そうとします。ただし検証環境の Chromium では、上で見たとおり、猫のページが短いと一覧の位置は戻りませんでした。ほかのブラウザでは試していません。
ScrollRestoration を置くと、history.scrollRestoration は manual になりました。ブラウザに任せず、React Router が位置を戻します。猫のページは長さにかかわらず上から開き、戻ったときは、どちらの長さでも一覧が 2000 の位置に戻りました。
位置が戻るのは、ローダーが読み込み終えてから
戻るボタンを押してから、50ミリ秒ごとに、表示されているページと scrollY を記録しました(ScrollRestoration あり、猫のページが長い場合。+455ms と +509ms の2行は省いています)。
戻るを押した後:
+ 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 には、次の値が入っていました。
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 を返します。
<ScrollRestoration getKey={(location) => location.pathname} />
こうすると、「一覧へ」のリンクで移ったときも、一覧は 2000 の位置で表示されました。リファレンスでも、パス名だけを使うと、前に開いたパスへあとから移動したときに位置が戻る、と説明されています。
ページの途中のタブを押したときは preventScrollReset を付ける
ScrollRestoration を置くと、新しい移動ではページがいちばん上に戻ります。クエリ文字列でタブを切り替えるような移動では、いちばん上に戻ると困ることがあります。
検証では、猫のページの途中(上から約1,200px)に、?tab=meals へのリンクを並べました。比べるために、preventScrollReset を付けないリンクも1つ置いています。
<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 に戻りました。
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 }) と書きます。





