かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

React Hooks React-router-dom ページ遷移した時にページのスクロール位置をリセットしたい

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 みたいでした。


[参考]

みんなでアジャイル ―変化に対応できる顧客中心組織のつくりかた

みんなでアジャイル ―変化に対応できる顧客中心組織のつくりかた

  • 作者:Matt LeMay
  • 発売日: 2020/03/19
  • メディア: 単行本(ソフトカバー)

スクロール

スクロール