🧭

React + History API で Router を実装する覚書

2023/12/13に公開

https://developer.mozilla.org/en-US/docs/Web/API/History_API

React 製のアプリを作るときに react-router が正規表現の対応をやめて非常に使えなくなったので(一般的なアプリケーションでは問題ないと思うけど、今回作っているアプリでは大いに問題があった)、History API を使って自前で実装した。一般的にはそうする意味はあまりなく、react-router でない類似のパッケージを入れたら良いと思う。(個人的には https://github.com/molefrog/wouter がおすすめ、結局使ってないけど)


事前知識

History API

今回改めて History API について調べた。先に知っておきたかったことは以下。

  • history.pushState / history.replaceState はイベントを発火しない。popstate イベントはあくまでブラウザによる戻る・進むの動作時に発火する。履歴の操作処理が副作用を持つのは微妙ってことだろう。
  • history.pushState / history.replaceState の第一引数 state はなくても良いし、第二引数は使われてない。
  • PopStateEvent クラスが定義されている。PopStateEventEvent の違いはオプションとして statehistory.pushState / history.replaceState の第一引数)を保持するかどうか。

https://developer.mozilla.org/en-US/docs/Web/API/PopStateEvent/PopStateEvent

React

React でルートの変更時に ReactNode を再レンダリングする必要があるが、どうやるのが良いか。多分 Router Component がルートの変更を検出して、自身の key をルートによってユニークな値に変更するのが良さそう。

https://react.dev/reference/react/useState#resetting-state-with-a-key

key が変更されるとコンポーネント全体が別物として再レンダリングされる。

実装の概要

細かい実装はまだこなれていないし適当なので、あくまでコンセプトだけ。今手で書いてるので動かないかもしれない。

useRoute.ts
import { createContext, useContext } from "react";

export const navigate = (to: string, options = { replace: false }) => {
  const event = options.replace ? "replaceState" : "pushState";
  history[event].call(history, null, "", to);
  dispatchEvent(new Event(event));
};

export const useNavigate = () => ({ navigate });

export type Route = {
  path: string;
  search: URLSearchParams;
  hash: string | undefined;
};

export const RouteContext = createContext<Route>({
  path: "/",
  search: new URLSearchParams(),
  hash: undefined,
});

export const useRoute = () => useContext(RouteContext);
Router.tsx
import { FC, PropsWithChildren, useCallback, useEffect, useState } from 'react';
import { Route, RouteContext } from './useRoute';

const events: Array<string> = [
  "popstate",
  "hashchange",
  "pushState",
  "replaceState",
];

export const Router: FC<PropsWithChildren> = ({ children }) => {
  const [route, setRoute] = useState<Route>({
    path: "/",
    search: new URLSearchParams(),
    hash: undefined,
  });
  const refresh = useCallback(() => {
    const { pathname, search, hash } = window.location;
    setRoute({
      path: pathname,
      search: new URLSearchParams(search),
      hash: hash == "#" ? undefined : hash,
    });
  }, [setRoute]);
  useEffect(() => refresh(), [refresh]);
  useEffect(() => {
    events.forEach(e => window.addEventListener(e, refresh))
    return () => events.forEach(e => window.removeEventListener(e, refresh));
  }, [refresh]);
  const key = route.path + "?" + route.search.toString() + (route.hash || "#")
  return (
    <RouteContext.Provider key={key} value={route}>
      {children}
    </RouteContext.Provider>
  )
};

あとは適当に Body 内部でルーティング処理を書けば良い。ルーティング処理そのものはお好みでコンポーネントを切り替えるだけなので適当にする。

useRouter().navigate を使って画面遷移すると "pushState" / "replaceState" イベントが発火する。それを Router が購読していて refresh が起動し、window.location を拾ってきて route が更新される。route の更新によって Router の key が変更され、子DOMが再レンダリングされる。

Link コンポーネントの参考はこんな感じ。Props は適当にやる。

Link.tsx
import { FC, PropsWithChildren } from 'react';

import { useNavigate } from './useRoute';

export const Link: FC<PropsWithChildren<{ to: string }>> = ({ to, children }) => {
  const { navigate } = useNavigate();
  return (
    <a
      href={to}
      onClick={click => {
        click.preventDefault();
        navigate(to);
      }}
    >
      {children}
    </a>
  );
};

navigate の先は Event によって処理されているので、すべての LinkRouter 以下に入っている必要はない。Router コンポーネントで囲むのはあくまで useRoute の内容によってDOM ツリーを差し替えたい領域だけ。


いわゆるルーティングの DSL としての Route コンポーネントの実装はやってないけど、それを使いたいなら wouter など使ったら良いと思う。

Discussion