React-router-dom を使うと簡単に SPA のルーティングができたのですが、スクロールした状態でページが切り替わると元のスクロール量が残り切り替わったページもスクロールされた状態で描画されしまう問題に直面ました。
windowのスクロールは、VDOMの世界外のことなので useEffect
内でURLが変わった時にスクロール量をリセットしてあげるのが良さそうです。
useHistory Hook を使う
useHistory
The useHistory hook gives you access to the history instance that you may use to navigate.
cf. React Router: Declarative Routing for React.js
React-router-dom の useHistory
hook を使って history オブジェクトが変更された際に window のスクロール量をリセットしてあげれば良さそうです。
ScrollTop.js
import { useEffect } from 'react'; import { useHistory } from 'react-router-dom'; export default function ScrollTop() { const history = useHistory(); useEffect(() => { // unlisten する為の関数が返される const unlisten = history.listen(() => { // history を監視して変更された際にスクロール量を0にする window.scrollTo(0, 0); }); // unmount された際に history.listen を unlisten return () => unlisten(); }, [history]); return null; }
App.js
import React from 'react'; import { BrowserRouter, Router, Switch } from 'react-router-dom'; import ScrollTop from './ScrollTop'; function App() { return ( <BrowserRouter> <ScrollTop /> <Switch> <Router exact path="/"> <Home /> </Router> <Router exact path="/page"> <Page /> </Router> </Switch> </BrowserRouter> ); }
<ScrollTop />
コンポーネントは表示するものが無くてちょっと気持ち悪いですが、これで router でページが切り替わった時にスクロール量がリセットされるようになりました!
history の変更の時点で scroll 量をリセットしているので、 アンカーなどのページ内リンクの場合でも <ScrollTop />
でスクロール量をリセットした後に、ハッシュの位置に移動するので問題ないように思います。
試しに下記のように history.listen
のコールバック関数内でタイマーを使ってスクロール位置をリセットするようにしてみます。
useEffect(() => { const unlisten = history.listen(() => { setTimeout(() => { window.scrollTo(0, 0); }, 1000); }); return () => unlisten(); }, [history]);
ページが切り替わった後 1秒後にスクロール量がリセットされます。こうするとページ内リンクで遷移した場合は、ハッシュの位置まで移動した後にスクロール量がリセットされる挙動になりました。
つまり、history.listen
は window がスクロールを決める前にリセットをすることができるということになっているのだと思います。
useLocation hook の場合
React-router-dom には location を取得できる useLocation
もあります。
useLocation
The useLocation hook returns the location object that represents the current URL. You can think about it like a useState that returns a new location whenever the URL changes.
cf. React Router: Declarative Routing for React.js
これを使っても同じ様にスクロールをリセットすることができましたが、location が変わった後に useEffect
内でスクロール量をリセットすることになるので、先のページ内リンクの場合でもスクロール量がリセットされてしまう問題があり、ページ内リンクがあるサイトの場合はその点を工夫する必要がありそうです。
e.g.
import { useEffect } from 'react'; import { useLocation } from 'react-router-dom'; export default function ScrollTop() { const location = useLocation(); useEffect(() => { window.scrollTo(0, 0); // url が変更されてからスクロール量がリセットされる }, [location]); return null; }
所感
Router のパッケージなので、遷移してたら当然ページのトップになると思っていたのですが、SPAだとスクロール量が残るんだな〜と気づけてなかったので、良い機会になりました。
また、感覚的には useLocation
の方が URL が変わってページが変わったらスクロールをリセットという感じでしっくり来たのですが、それはアンカーなどのページのスクロールが決められた後で、強制的にリセットすることになるから、useHistory
で実際のページが変わる前にリセットするのが正しい、つまり history -> location の順で変更されていると気付ける機会になり大変良かったです。
おまけ
react-router-scroll-top
ってパッケージもありましたが、中が class component みたいでした。
[参考]
- React Router: Declarative Routing for React.js
- React Router: Declarative Routing for React.js
- javascript - react-router scroll to top on every transition - Stack Overflow
- history.History.listen JavaScript and Node.js code examples | Codota
- react-routerとreact-router-domの違い - Qiita
- React-routerメモ - Qiita

みんなでアジャイル ―変化に対応できる顧客中心組織のつくりかた
- 作者:Matt LeMay
- 発売日: 2020/03/19
- メディア: 単行本(ソフトカバー)