仮想スクロールで実装した一覧にブラウザバックした際に元の位置に戻す方法

に公開

こんにちは!CastingONEの大沼です。

始めに

カードUIの一覧でスクロールしてからどれか選択して詳細に遷移した後、ブラウザバックした場合は選択した時の場所に戻ってくれると嬉しいと思います。スクロール位置の復元というのはルーティングのライブラリで大体scrollRestorationというオプションをONにすることで自動で復元してくれますが、これはあくまで画面全体のwindow.scrollに関するスクロール位置で、仮想スクロールなど中でスクロールする場合は復元することができません。こういったケースでは自前で状態を保存しておき、ブラウザバックなどで戻ってきた時に自前でスクロール位置を調整する必要があります。
このような自前でブラウザ履歴に紐付けて状態の保存・復元をしたい場合にどのように実装したか、備忘録としてまとめました。

今回使ったライブラリは以下のものを使用し、これらを使った場合の実装について説明します。ルーティングについてはNext.js Pages Routerでの実装をメインに説明し、React Routerはあくまで補足としての説明にとどめます。なお、Next.js App Routerについては詳細は後述しますがそもそも実装することができませんでした😓

検証コード

今回検証で作ったものは人物リストの仮想スクロールで検索で絞り込むことができるもので、このリストからどれかカードを選択して詳細画面に遷移した後、一覧にブラウザバックすると検索文字含めて元に位置に戻るものになっています。

検証アプリはStackBlitzで作ったので詳細のコードや実際に触ってみたい方はこちらをご参照ください。

仮想スクロールで実装した一覧にブラウザバックした際に元の位置に戻す方法

表示に必要なレスポンスデータをキャッシュする

まずスクロール位置を復元する前に表示するデータが無いとそもそもスクロールすることができません。TanStack/Queryでは取得したデータを一定時間キャッシュすることができるため、この時間を無限にすることでブラウザバック直後でもデータを表示することができます。
しかしキャッシュを無限にすると今度は検索パラメータを変えたりブラウザバックではなく単純に新しいナビゲーションで遷移してきた時もデータがリフレッシュしない問題が発生します。これは明示的にキャッシュをクリアする必要があり、今回のケースでは現在の検索パラメータと関係ないキャッシュはクリアしても問題ないため、以下のように取得成功後に表示中のquery以外のキャッシュを消すことで検索パラメータを変えるごとに再取得できる状態を維持できます。

取得データをデフォルトでは永続化して、表示中のquery以外のキャッシュは手動で削除する
 const getPeopleQueryKey = (query: PeopleQuery) => ['people', query];

 export const useQueryPeople = ({ query }: { query: PeopleQuery }) => {
   const queryClient = useQueryClient();
   const queryKey = getPeopleQueryKey(query);
 
   const result = useInfiniteQuery({
     queryKey,
     queryFn: (option) => {
       return fetchPeople({
         query,
         page: option.pageParam ?? 1,
       });
     },
     initialPageParam: undefined as number | undefined,
     getNextPageParam: (res) => {
       return res.nextPage;
     },
     placeholderData: keepPreviousData,
+    // ブラウザバック時に元のデータとスクロール位置を復元させるため、キャッシュを無期限に保持する
+    // データを更新したい場合は手動でrefetchする
+    staleTime: Infinity,
+    gcTime: Infinity,
   });

+  // deps回避のためにrefで参照
+  const queryKeyRef = useRef(queryKey);
+  queryKeyRef.current = queryKey;
+  // 取得に成功したら、表示中のquery以外のキャッシュを削除しておく
+  useEffect(() => {
+    if (result.isFetching) {
+      return;
+    }
+
+    queryClient.removeQueries({
+      // people関連のqueryキーを対象にする
+      queryKey: queryKeyRef.current.slice(0, 1),
+      type: 'inactive',
+      predicate: (query) => {
+        return (
+          JSON.stringify(query.queryKey) !== JSON.stringify(queryKeyRef.current)
+        );
+      },
+    });
+  }, [result.isFetching, queryClient]);

   return result;
 };

TanStack Query DevToolsで見ると、"people"配下のqueryKeyは表示中のものだけ残っており、それ以外が削除されていることが確認できます。

新規のナビゲーションによるキャッシュのクリアは事前に行うのは難しいのでrefetchを明示的に実行して更新するようにしました。新規のナビゲーションかの判定は復元するスクロール位置があるかどうかで行えるので、詳細のコードはそちらの実装時に合わせて説明します。

ブラウザ履歴にスクロール位置などの情報を紐づける

表示するデータはキャッシュしてスクロールできる状態になったので、いよいよスクロール位置の保存・復元の方を行います。一番直接的な対応としてはhistory.stateにスクロール位置を保存してしまうことですが、Next.js Pages Routerもhistory.stateを使っている関係上、上書きされてしまって保存することができませんでした。ただ識別用でkeyを保存していたので、それに紐づけたデータをsessionStorageに保存しておくことでブラウザ履歴に情報を保存したような振る舞いをすることができました。


Next.js Pages Router実行時のhistory.state

Next Routerのrouteキーに紐づくデータを取得・保存するutilityメソッド
/**
 * sessionStorageに保存するkeyを返す
 * @param routeKey - Next Routerで生成されたrouteキー
 */
const getStorageKey = (routeKey?: string) => {
  const PAGE_STATE_PREFIX = 'PAGE_STATE';
  /** Next Routerで生成されたrouteキー */
  const finalRouteKey = routeKey ?? history.state?.key ?? '';
  return `${PAGE_STATE_PREFIX}:${finalRouteKey}`;
};

/**
 * 指定したrouteKeyに紐づくsessionStorageのデータを取得する。失敗したらnullを返す
 * @param routeKey - Next Routerで生成されたrouteキー
 */
const getStorageData = <T>(routeKey?: string): Partial<T> | null => {
  if (typeof window === 'undefined') {
    return null;
  }
  try {
    return JSON.parse(
      sessionStorage.getItem(getStorageKey(routeKey)) ?? ''
    ) as Partial<T>;
  } catch {
    return null;
  }
};

/**
 * 指定したrouteKeyに紐づくデータをsessionStorageに保存する
 * @param data - 保存するデータ
 * @param routeKey - Next Routerで生成されたrouteキー
 */
const saveStorageData = <T>(data: T, routeKey?: string) => {
  if (typeof window === 'undefined') {
    return;
  }
  try {
    sessionStorage.setItem(getStorageKey(routeKey), JSON.stringify(data));
  } catch {}
};

これらのutilityメソッドを使って、現在のブラウザ履歴の状態の取得とURL変更時に保存するhooksを書くと以下のようになります。history.stateの参照で事前にlet beforeState = history.state;として変数で持っておくのがポイントで、Next.js Pages Routerのブラウザバックではrouter.beforePopStateを使ったとしてもhistoryがpopしてからイベントが発火するため、history.stateをそのまま参照すると遷移先の状態を見ることになってしまいます。事前に変数として持っておくとhistory.stateのアクセスタイミングを気にする必要がなくなるので、ブラウザバックによるpopstateイベントと通常のナビゲーションによるrouteChangeStartの場合分けをする必要がなくなり、どちらのケースでもイベントが発火するrouteChangeStartをトリガーにして保存処理を行っています。

ページ固有の状態を保存・取得するためのカスタムフック
/**
 * history.state にページ固有の状態を保存・取得するためのカスタムフック
 * @note 厳密にはNext Routerで生成されたkeyに紐づいたsessionStorageを操作している(history.stateに保存するとNext Routerに上書きされて消えてしまうため)
 */
export const usePageStateInHistory = <T>({
  onSaveAtRouteChangeStart,
}: {
  /**
   * ページ変更時に状態を保存するためのコールバック関数
   */
  onSaveAtRouteChangeStart?: () => Partial<T>;
} = {}) => {
  const [pageState, setPageState] = useState<Partial<T>>(() => {
    return getStorageData<T>() ?? {};
  });

  const savePageState = useCallback(
    (partialState: Partial<T>, routeKey?: string) => {
      setPageState((currentState) => {
        // 他で更新されている可能性を考慮してstorageからのデータを優先的に使う
        const finalCurrentState = getStorageData<T>(routeKey) ?? currentState;
        const newPageState: Partial<T> = {
          ...finalCurrentState,
          ...partialState,
        };
        saveStorageData(newPageState, routeKey);
        return newPageState;
      });
    },
    []
  );

  const { events } = useRouter();
  // depsから外すためにrefで参照する
  const onSaveAtRouteChangeStartRef = useRef(onSaveAtRouteChangeStart);
  onSaveAtRouteChangeStartRef.current = onSaveAtRouteChangeStart;
  useEffect(() => {
    // ブラウザバックするとpop前の情報が消えてしまうので、事前に保存しておく
    let beforeState = history.state;

    const handleRouteChangeStart = () => {
      if (onSaveAtRouteChangeStartRef.current == null) {
        return;
      }
      const partialState = onSaveAtRouteChangeStartRef.current();
      savePageState(partialState, beforeState.key);
    };
    const handleRouteChangeComplete = () => {
      beforeState = history.state;
    };

    events.on('routeChangeStart', handleRouteChangeStart);
    events.on('routeChangeComplete', handleRouteChangeComplete);
    return () => {
      events.off('routeChangeStart', handleRouteChangeStart);
      events.off('routeChangeComplete', handleRouteChangeComplete);
    };
  }, [events, savePageState]);

  return {
    /** history.stateに保存した情報 */
    pageState,
    /**
     * history.state を更新する関数
     * @param partialState - 更新する状態の部分オブジェクト
     */
    // このメソッドを返すことで好きなタイミングで保存できるようになるが、今回は使用しない
    // savePageState,
  };
};

あとはこのhooksを使って保存・取得することで完了です。スクロール位置以外も保存できるため、検索ワードも保存・取得することにしました。(実際はURLクエリに含めた方が良い気はします)

usePageStateInHistoryを使ってブラウザ履歴に情報を保存・取得する
 import type { FC } from 'react';
 import { useState, useMemo, useRef } from 'react';
 import { Box } from '@mui/material';
 import { Virtualizer } from '@tanstack/react-virtual';
 import { useMount } from 'react-use';
 
 import { useQueryPeople } from '~/api/useQueryPeople';
 import { DebouncedSearchWord } from '~/components/DebouncedSearchWord';
 import { PersonCard } from '~/components/PersonCard';
 import { InfiniteScroller } from '~/components/InfiniteScroller';
 import { usePageStateInHistory } from '~/hooks/usePageStateInHistory';

 export const HomePage: FC = () => {
+  const virtualizerRef = useRef<Virtualizer<HTMLDivElement, Element> | null>(
+    null
+  );

+  const { pageState } = usePageStateInHistory<{
+    scrollOffset: number;
+    searchWord: string;
+  }>({
+    onSaveAtRouteChangeStart: () => {
+      return {
+        scrollOffset: virtualizerRef.current?.scrollOffset ?? undefined,
+        searchWord,
+      };
+    },
+  });

   const [searchWord, setSearchWord] = useState(
+    (): string => pageState?.searchWord ?? ''
   );
   const {
     data,
     isFetching,
     hasNextPage,
     fetchNextPage,
     isFetchingNextPage,
   } = useQueryPeople({
     query: {
       searchWord,
     },
   });

   const people = useMemo(() => {
     if (data == null) {
       return [];
     }
     return data.pages.map((page) => page.people).flat();
   }, [data]);

   return (
     <Box sx={{ display: 'flex', flexDirection: 'column', flex: '1 1  0' }}>
       <DebouncedSearchWord
         searchWord={searchWord}
         debounceTime={500}
         onChangeDebouncedSearchWord={setSearchWord}
       />
       <Box sx={{ mt: 1, flex: '1 1 0', overflow: 'hidden' }}>
         <InfiniteScroller
+          virtualizerRef={virtualizerRef}
           loading={data == null || (isFetching && !isFetchingNextPage)}
+          // 初期のスクロール位置を設定
+          initialOffset={pageState?.scrollOffset}
           items={people ?? []}
           estimateSize={() => 150}
           nextDataFetchable={hasNextPage}
           fetchNextDataHandler={async () => {
             await fetchNextPage();
           }}
         >
           {({ item }) => (
             <Box sx={{ pb: 1 }}>
               <PersonCard person={item} />
             </Box>
           )}
         </InfiniteScroller>
       </Box>
     </Box>
   );
 };

前セクションで軽く話したデータの再取得ですが、今回のケースだとpageState.scrollOffsetがない場合はスクロール位置の復元がないケースになるので、その場合refetchを実行して再取得を促すようにします。ただし初回アクセスでそもそもデータがなく初めての取得の場合は自動で取得が始まっているため、isFetchingがfalseの時のみ実行します。このチェックなしでrefetchを実行すると処理がコンフリクトするのか、処理が止まってしまうことがあったのでこのチェックを入れています。

スクロール位置の復元ができない場合は新規画面なのでrefetchしてデータを最新にする
 const HomePage: FC = () => {
   const {
     data,
     isFetching,
+    refetch,
     hasNextPage,
     fetchNextPage,
     isFetchingNextPage,
   } = useQueryPeople({
     query: {
       searchWord,
     },
   });

+  useMount(() => {
+    // スクロール復元位置が分からない、
+    // かつまだ読み込み始めていない場合は明示的にrefetchする
+    if (pageState.scrollOffset == null && !isFetching) {
+      refetch();
+    }
+  });

   // 省略
 }

これでブラウザバックの時はデータの再取得なしで元の場所を表示し、ナビゲーションなどによって一覧に飛んできた場合は最新のデータを取得するようになりました。

それ以外のルーティングライブラリの場合

以上が仮想スクロールのスクロール位置をブラウザバック時に復元する方法でしたが、他のルーティングライブラリではどうなるかも検証しました。

Next.js App Routerの場合

Next.js App Routerは冒頭でも書きましたが実装できませんでした。。App RouterもPages Routerと同様にhistory.stateが使われていますが、keyみたいな識別可能な情報が見当たらず、今回の方法で実装するのは難しそうでした。


Next.js App Router実行時のhistory.state

history.stateの上書きはされなかったのでhistory.replaceStateで直接保存するという方法が取れそうではありますが、タイミングが難しいです。App Routerには画面遷移のイベントを取得する方法がないため、離脱直前に保存するということができません。絶対やめた方が良いですが、スクロールする度にその位置をhistory.replaceStateで更新し続ければもしかしたら実現はできるかもしれません・・・。

したがって完全に復元することは諦めて、例えば直前の画面のURLをProvider経由で管理しておき、詳細から来た場合は該当のページに当たるカードが見えるようにスクロールするなどの実装でそれっぽい振る舞いをするとかがApp Routerでやれることなのかなと思いました🤔

React Routerの場合

React Routerはなんと最初からhistory.stateへのアクセスを考慮した設計になっており、navigateなど遷移系のメソッドやコンポーネントにstateを渡すことができます。

navigate({
  pathname: "/some/route",
  search: "?search=param",
  hash: "#hash",
  // カスタムのstateを保存できる
  state: { some: "state" },
});

https://reactrouter.com/api/hooks/useNavigate#navigate-with-a-to-object

保存するとhistory.state.usrに保存されるようで、ユーザ側で設定したデータを保存する場所がしっかり確保されていました。


React Router実行時のhistory.state

ここで保存したstateuseLocationlocation.stateで参照することができ、これらを使うとNext.js Pages Routerで実装したusePageStateInHistoryの実装がかなりシンプルになります。ただ画面遷移のトリガーがstate更新用のnavigate実行がコンフリクトしてしまってそのまま使えなかったので、直接history.replaceStateを実行することにしました。わざわざ開発者向けにhistory.state.usrの枠を用意しているだけあってこれが上書きされて消えることがなかったので問題なく動作しました。
また画面遷移のトリガーに使っているuseBlockerですが、Next.js Pages Routerと違ってpopstateであってもしっかりURLが切り替わる前の状態で発火するため、history.replaceStateも目的の場所で更新できます。

React Router版のusePageStateInHistory
import { useCallback } from 'react';
import { useLocation, useNavigate, useBlocker } from 'react-router';
import type { Location } from 'react-router';

/**
 * history.state にページ固有の状態を保存・取得するためのカスタムフック
 * @note 厳密にはReact Routerで生成されたkeyに紐付けてメモリで管理していそうなので、リロードしたら消える
 */
export const usePageStateInHistory = <T>({
  onSaveAtRouteChangeStart,
}: {
  /**
   * ページ変更時に状態を保存するためのコールバック関数
   */
  onSaveAtRouteChangeStart?: () => Partial<T>;
} = {}) => {
  const location: Location<Partial<T> | null> = useLocation();
  const pageState: Partial<T> = location.state ?? {};
  const navigate = useNavigate();

  const savePageState = useCallback(
    (partialState: Partial<T>) => {
      // navigateを使うとuseBlockerがまた発火してしまうため、replaceStateで更新する
      // navigate({}, {
      //   replace: true,
      //   state: {
      //     ...pageState,
      //     ...partialState,
      //   },
      // });
      history.replaceState(
        {
          ...history.state,
          usr: {
            ...pageState,
            ...partialState,
          },
        },
        ''
      );
    },
    [navigate, pageState]
  );

  // 画面遷移前のハンドリング
  useBlocker(() => {
    if (onSaveAtRouteChangeStart == null) {
      return false;
    }

    const partialState = onSaveAtRouteChangeStart();
    savePageState(partialState);

    // 遷移をブロックするわけではないのでfalseを返す
    return false;
  });

  return {
    /** history.stateに保存した情報 */
    pageState,
    /**
     * history.state を更新する関数
     * @param partialState - 更新する状態の部分オブジェクト
     */
    savePageState,
  };
};

もしhistory.replaceStateを直接実行することに不安がある場合は、Next.js Pages Routerの時のようにkeyがあったので、同じようにそのkeyに紐づけてsessionStorageに保存する方法もできます。
※keyは最初のページでは生成されず、次のページからできるため、スクショのhistory.stateではkeyがありませんでした。ちなみにkeyが入っていない状態でlocation.keyを見るとdefaultが入っていました。

Next.js Pages Routerの実装のようにsessionStorageに保存する場合
export const usePageStateInHistory = <T>({
  onSaveAtRouteChangeStart,
}: {
  /**
   * ページ変更時に状態を保存するためのコールバック関数
   */
  onSaveAtRouteChangeStart?: () => Partial<T>;
} = {}) => {
  // 省略

  // 遷移前にフックして、ページ情報を保存する
  useBlocker(({ currentLocation }) => {
    if (onSaveAtRouteChangeStart == null) {
      return false;
    }

    const partialState = onSaveAtRouteChangeStart();
    savePageState(partialState, currentLocation.key);

    return false;
  });

  // 省略
}

検証コードは以下で書きましたので、詳細のコードが気になる方はこちらをご参照ください。

終わりに

以上が仮想スクロールで実装した一覧にブラウザバックした際に元の位置に戻す方法でした。Next.jsは離脱アラートの実装もですが結構制約があってApp Routerでは実装できませんでしたが、Pages Routerではkeyがあったことで実装することができました。React Routerは初めからhistory.stateに保存する機構が用意されており、Next.jsよりもシンプルなコードで実装することができました。
ブラウザバック時に状態を復元する実装をする際の参考になれば幸いです。

Discussion