stin's Blog

react-router の型定義が知らない間にきれいになっていたので使い方を再考する


@types/react-router v5.1.9 から型定義がきれいになっていた

react-router の型定義は受け取るパスが全て文字列のため、以前は TS コンパイラでパスの間違いを検出できませんでした。

そのため僕は typed-path-builder というライブラリを作って次の記事を書きました。

typed-path-builder は TypeScript の template literal types を駆使していますが、 @types/react-router v5.1.9 もそれを利用してパスパラメータを推論するように変更が加えられていました。

業務で v4 系を使っていて更新していなかったためこの変更にまったく気づいていませんでした。「いつの間に…!」と思って DefinitedlyTyped のプルリクエストを探してみたら、なんと 2020 年 12 月 28 日 にマージされています。

typed-path-builder を作る前にすでにマージされています。気づかずに typed-path-builder を作っていたのが非常に恥ずかしい…。

そんなことはさておき、強化された型定義を最大限活用して型安全なアプリケーションを構築する書き方を考え直しました。この記事ではそんな話をしようと思います。

用語の定義

本記事内だけの用語を予め定義します。

  • 抽象パス

    /foo/:fooId/bar/:barId のようにパスパラメータが入力される箇所を指定したパス文字列を指します。 react-router では <Route>path props が一つの例です。

  • 具象パス

    /foo/1234/bar/5678 のようにパスパラメータに具体的な値を指定したあとのパス文字列を指します。 react-router では <Link>to props (string 型の場合) が一つの例です。

どのような変更がされているか

template literal types を使って抽象パスからパスパラメータを推論できるようになっていました。 対象は <Route> コンポーネントと generatePath 関数です。

Route コンポーネント

<Route
  path="/:fooId/:barId"
  render={({ match }) => (
    <div>
      <p>{match.params.fooId}</p>
      <p>{match.params.barId}</p>
      <p>{match.params.bazId}</p> {/* error! */}
    </div>
  )}
/>

<Route> コンポネントの path props に文字列リテラルを渡すと、パスパラメータを推論して match.params の型が確定します。

上記では render props を例にしていますが、 children の render prop でも同様です。 (render prop とは: https://ja.reactjs.org/docs/render-props.html)

generatePath

const route = generatePath("/:fooId/:barId", {
  fooId: "foo",
  barId: "bar",
  bazId: "baz", // error!
});

generatePath の第 1 引数に文字列リテラルを渡すと第 2 引数のオブジェクトに渡すべきプロパティの型が確定します。

template literal types を使っているため、文字列リテラルではなく string 型を渡すと当然推論できなくなります。

let base = "/:fooId/:barId";
const route = generatePath(base, {
  fooId: "foo",
  barId: "bar",
  bazId: "baz", // error …じゃない!
});

<Route> も同様です。

どのように使えば安全になるかを考える

型定義がどのように型安全になっているかを把握した上で、使い方を考えていきます。使い方次第では、やりたい放題書いても TS コンパイラが検出してくれないのでルールを作ります。

抽象パスは一元管理する

抽象パスからパスパラメータを推論させるためには文字列リテラル型で渡す必要があります。が、 <Route> を使用するたびに path に文字列を直接入力していたのでは、アプリケーションにとって存在しない抽象パスを指定できてしまいます。

そこで、アプリ全体の抽象パスを一元管理するオブジェクトを用意して、抽象パスを指定する時はこのオブジェクトのプロパティから取り出すことにします。アプリ改修によってルーティングの追加、削除が行われる場合はまずこのオブジェクトを修正することでコンパイルエラーを検出します。

export const Paths = {
  users: "/users",
  user: "/users/:userId",
  userTweets: "/users/:userId/tweets",
  userTweet: "/users/:userId/tweets/:tweetId",
  settings: "/settings",
} as const;

as const をつけることで値が書き換えられることがなく、 各プロパティは文字列リテラル型になります。

<Route>path を始めとした、抽象パスを指定する箇所では Paths オブジェクトから読み取ることをコーディングルールにします。

import { Route } from "react-router-dom";
import { Paths } from "./path/to/constant";

<Route
  path={Paths.userTweet}
  render={({ match }) => (
    <div>
      <p>{match.params.userId}</p>
      <p>{match.params.tweetId}</p>
    </div>
  )}
/>;

具象パス指定には必ず generatePath を使う

<Link> コンポーネントの to などは以下のように型推論をさせる方法がないため、依然として string 型を全て受け付けてしまいます。

type LinkProps = {
  to: `/foo/${string}/bar/${string}`; // このように推論させることはできない
};

to が受け付ける型の制約を強くすることはできないので、値の渡し方に決まりを設けます。 型定義のアップデートで改良された generatePath を使うことです。

import { generatePath } from "react-router-dom";
import { Paths } from "./path/to/constant";

<Link to={generatePath(Paths.userTweets, { userId: "stin_factory" })}>
  go to user tweet list
</Link>;

Paths から文字列リテラル型の抽象パスを取り出して generatePath と組み合わせることで具象パスを指定します。

history のメソッドにわたす具象パスも同様ですね。

import { useHistory, generatePath } from "react-router-dom";
import { Paths } from "./path/to/constant";

const history = useHistory();

history.push(generatePath(Paths.user, { userId: "stin_factory" }));
history.replace(generatePath(Paths.userTweets, { userId: "stin_factory" }));

このように記述するようにしておけば、抽象パスの修正によってコンパイルエラーを吐き出させることが可能です。

useParams について

v5.1.9 以降も useParams の型定義はアップデートされていません。つまり、相変わらず使用者が自分で型を指定することになります。

const { fooId } = useParams<{ fooId: string }>(); // fooId 本当に取り出せる???

これではせっかく抽象パスを一元管理するようにしたのに、存在しないパスを指定できてしまいます。

useParams についての僕の考えは「使わない」です。

そもそも useParams<Route path="foo/:fooId"> が祖先コンポーネントにいる場合に初めて fooId パラメータを取得できるようになります。 しかしそれはしばしば React 界隈で話題になる「親と子の密結合問題」そのままではないでしょうか?

  • 子は親が <Route> の中に自分をレンダリングしてくれることを知っているから useParams を使用できる
  • 親は子が useParams を使うことを知っているから <Route> で囲ってやる必要がある

パスパラメータは高頻度で使用する値のため、そこら中でこの問題が起こることになります。それはあまり嬉しくないかもしれません。

そこで useParams は使わずに props 経由でパスパラメータで受け取ることをルールにします。

type Props = {
  userId: string;
  tweetId: string;
};

const TweetView: VFC<Props> = ({ userId, tweetId }) => {
  const { data } = useSWR(
    [userId, tweetId],
    (userId, tweetId) => `Tweet: ${userId} ${tweetId}`,
  );

  return <p>{data}</p>;
};

こうすることで、子は <Route> の中でしか使えないという親依存から開放されます。 また、子の Props は親に対してパスパラメータを渡すことを義務付けており、必ず値を取得することができます。

親側では、 <Route>render がパスパラメータの型推論されているため、 render prop パターンを用いて型安全にパスパラメータを子に引き継ぎます。

const Parent: VFC = () => {
  return (
    <Route
      path={Paths.userTweet}
      render={({ match }) => (
        <TweetView userId={match.params.userId} tweetId={match.params.tweetId} />
      )}
    />
  );
};

このように親子を構成すれば useParams は不要になり、親子の依存は Props インターフェイスで義務付けられた部分のみ、 型の安全性も担保されている状態にできます。

まとめ

この記事では強化された react-router の型定義を最大限活用する React アプリケーションの作り方を考えてきました。

抽象パスはオブジェクトで一元管理、具象パスは必ず generatePath で生成、 useParams は使用しないのが自分の中のルールになっています。

これらのコーディングルール自体に違反した場合は(eslint-rules でも作らない限り)当然ながら型安全性は失われてしまいます。 コーディングルールではなくすべて TS コンパイラで検出したんだという場合はルーティングライブラリを乗り換えるしかないでしょう。 (Rocon 等)

僕自身は @types/react-router の更新でかなり開発者体験がよくなったと感じています。

それではまた!