🤖

TanStack Routerの型安全なsearch paramsを試してみた

2024/12/31に公開

始めに

Reactでルーティングライブラリというとreact-routerが有名だと思いますが、最近はTanStackでもルーティングライブラリを提供を始めました。

https://tanstack.com/router/latest

こちらはとにかく全ての場所に型をつけたり、開発体験に特化した実装をしており、他のライブラリでは難しいURLの ? 以降につくsearch paramsにも型をつけることができます。これによってダイアログの開閉フラグやページネーションのページ番号などの状態をsearch paramsで管理しやすくなり、直リンクや画面リフレッシュをしても状態を維持した実装を行いやすくなります。これは公式でも推している内容になります。
search paramsのメリットについてはこちらの記事でも紹介されており、他にも色々メリットが紹介されていてとても良さそうに思いました。

https://zenn.dev/aishift/articles/ad1744836509dd#🦋-search-paramsによる状態管理

そこでTanStack Routerについて素振りをしてみて、特にsearch paramsを使ってみた所感をまとめてみました。

検証コード

今回は以下の状態をsearch paramsに入れて動かしてみました。StackBlitzで実装したので動作やソースコードが気になる方は是非こちらをご参照ください。

  • ダイアログの開閉
  • タブの選択
  • テーブルのページネーション、ソート、キーワード

type-safeなsearch paramsの設定

ダイアログの開閉フラグをsearch paramsで定義する場合、createFileRoutevalidateSearchにtype-safeな設定をする必要があります。スクラッチでも書けますが手間を減らすためにzodを使って設定します。
具体的には以下のようなコードになります。

ダイアログの開閉フラグをsearch paramsで定義
import { createFileRoute } from '@tanstack/react-router';
import { fallback, zodValidator } from '@tanstack/zod-adapter';
import { z } from 'zod';

const defaultValues = {
  open: false,
};

const dialogSearchSchema = z.object({
  // バリデーションで失敗することも考えてfallbackやdefaultを設定する
  open: fallback(z.boolean(), defaultValues.open).default(defaultValues.open),
}) satisfies z.ZodType<typeof defaultValues, any, any>;

export const Route = createFileRoute('/dialog')({
  // URLに含まれているsearch paramsをパースする
  validateSearch: zodValidator(dialogSearchSchema),
  component: RouteComponent,
});

function RouteComponent() {
  //
}

コード上でもコメントを書いていますが、URLは直接書き込めることから不正な値が入る可能性があるため、fallback設定を書く必要があります。zodでも.catchでケアすることができますが、それをすると型推論が上手くいかなくなってしまうらしく、TanStack側で提供しているfallbackメソッドで設定する必要があります。これはあくまでバリデーションエラー時の設定で、ここからさらにオプショナルにしてデフォルト値を設定する場合は更に.default( )で値を定義する必要があります。

https://tanstack.com/router/latest/docs/framework/react/guide/search-params#validating-search-params

後は型とは関係ないですが、デフォルト値の時はURLにsearch paramsがついていると無駄に長くなってしまうので取り除く設定も入れると良いです。

デフォルト値のsearch paramsから取り除く
-import { createFileRoute } from '@tanstack/react-router';
+import { createFileRoute, stripSearchParams } from '@tanstack/react-router';
 import { fallback, zodValidator } from '@tanstack/zod-adapter';
 import { z } from 'zod';

 const defaultValues = {
   open: false,
 };

 const dialogSearchSchema = z.object({
   // バリデーションで失敗することも考えてfallbackやdefaultを設定する
   open: fallback(z.boolean(), defaultValues.open).default(defaultValues.open),
 }) satisfies z.ZodType<typeof defaultValues, any, any>;

 export const Route = createFileRoute('/dialog')({
   // URLに含まれているsearch paramsをパースする
   validateSearch: zodValidator(dialogSearchSchema),
+  search: {
+    // defaultValuesがsearchParamsが入っている場合はURLから取り除く
+    middlewares: [stripSearchParams(defaultValues)],
+  },
   component: RouteComponent,
 });

これで設定は完了で、作成されたRouteオブジェクトのhooksを呼び出すことでtype-safeになります😊

作成されたRouteオブジェクトを使ってtype-safeなhooksを実行
function RouteComponent() {
  // validateSearchで定義したtypeを元に取得できる型が推論される
  const { open } = Route.useSearch();
  const navigate = Route.useNavigate();

  const openDialog = () => {
    navigate({
      search: {
        open: true,
      },
    });
  };

  const closeDialog = () => {
    navigate({
      search: {
        open: false,
      },
    });
  };

  return (
    //
  )
}

この型はLinkを設定する場所でも反映されて、Linkコンポーネントのsearchもtypeチェックされます😊

触ってみて思ったこと

type-safeなsearch paramsを謳っているだけあって至る所で型が反映されており、概ね良かったですがいくつか気になったところがあったのでその辺を挙げていきます。

型の中身が複雑すぎる

上でsearch paramsで型エラーがちゃんと表示するのを確認しましたが、じゃあ正しい型はなんだろうなと思ってsearchの部分をhoverするとゴチャゴチャと型が出てきて最終的に何を入力するべきか分かりませんでした😓 これだといくら型安全だとしても入れるべき値が分からないのでちょっと困りそうだなと思いました(最悪該当画面のschemaを見たら分かりますが)

型が固すぎて設定しづらい

開閉フラグのスキーマ定義で以下のようにfallbackやdefaultで同じ値を設定したりと冗長な感じがありますが、後ろのdefaultを設定しないとsearchのところで必須設定する必要が出てしまって、仕方なく設定しています。厳密にするためには仕方のないことですが、もうちょっと簡単に設定できたらなぁと思いました。

const dialogSearchSchema = z.object({
  // バリデーションで失敗することも考えてfallbackやdefaultを設定する
  open: fallback(z.boolean(), defaultValues.open).default(defaultValues.open),
}) satisfies z.ZodType<typeof defaultValues, any, any>;

他にはテーブルのソートデータをスキーマ定義した際に、型を合わせたつもりが何故かエラーが起きてしまい、それの解消に苦戦しました。

const DEFAULT_SORT_MODEL: GridSortModel = [];
const sortModelSchema = fallback(
  z.array(
    z.object({
      field: z.string(),
      sort: z.enum(['asc', 'desc']),
    })
  ),
  DEFAULT_SORT_MODEL // ここがtypeエラー
).default(DEFAULT_SORT_MODEL) satisfies z.ZodType<GridSortModel, any, any>;

結論GridSortModelの方自体にnullやundefinedが入っているのが原因でしたが、エラーの場所によっては一つ前の型が複雑すぎるという問題も相まって原因特定にかなり苦労しました。。


GridSortModelと合わないというエラーだと、これに合わせてスキーマ書いたはずなのにってなって混乱した


sortにundefinedは入れられないというメッセージでGridSortModelにundefinedが入っている可能性を想像できた

https://github.com/mui/mui-x/blob/v7.23.5/packages/x-data-grid/src/models/gridSortModel.ts#L23-L40

https://github.com/mui/mui-x/blob/v7.23.5/packages/x-data-grid/src/models/gridSortModel.ts#L3

余談: StackBlitz上での罠

ちなみにStackBlitz上ではstrictNullChecksが効かないのか、hoverした時の型表示ではnullとundefinedが除外された形で表示されました。そのお陰でsortには"asc" | "desc"しかないじゃないかって思って原因特定がかなり遅くなりました。。

厳密に定義しても抜け道はある

上のように型の設定で苦労しながらなんとか実装できたとしても、想定しないケースは起こりえます。例えば一つ前のGridSortModelの例だと、fieldがstringなので存在しないフィールドキーを直接入力した場合、スキーマは通るけどテーブル上ではソートされません。今回使っているMUIのテーブルでは存在しないフィールドを入れるとクリアされるので問題ありませんが、その辺のケアを結局コンポーネント側でやる必要があります。そうするとここまで苦労して型を定義する必要があるのだろうか?という疑問が少し湧きました🤔

結局状態管理用のhooksでラップした方が使いやすいのでは?

ダイアログの開閉フラグをsearch paramsを使って以下のようなコードを書きましたが、個人的にはかなり長いと思いました。

TanStack Routerでsearch paramsの開閉フラグを管理するサンプルコード
function RouteComponent() {
  // 開閉フラグの管理コードだけで18行くらい使っている
  const { open } = Route.useSearch();
  const navigate = Route.useNavigate();
  
  const openDialog = () => {
    navigate({
      search: {
        open: true,
      },
    });
  };
  
  const closeDialog = () => {
    navigate({
      search: {
        open: false,
      },
    });
  };

  return (
    //
  )
}

本質的には状態管理をローカルステートではなくsearch paramsを使っているに過ぎないので、例えば以下のようにデータ参照&更新用のhooksがあると非常に扱いやすいです。

search paramsを使って状態管理するhooksの実装イメージ
const useBooleanStateBySearchParams = ({ key }: { key: string }) => {
  // 実装イメージ。実際はtypeエラーが出てしまう
  const { search } = useLocation()
  const navigate = useNavigate()

  const flag = search[key] === 'true'
  const setFlag = useCallback((newFlag: boolean) => {
    navigate((prev) => {
      const newSearchParams = {
        ...prev,
        [key]: newFlag
      }
      if (newFlag === false) {
        delete newSearchParams[key]
      }
      return newSearchParams
    })
  }, [key, navigate])

  return [flag, setFlag]
}

function RouteComponent() {
  // useStateと同じ感覚でデータを参照して、更新ができる
  const [isOpen, setIsOpen] = useBooleanStateBySearchParams({
    key: 'open',
  })
}

ここまでラップしたhooksを作ってしまえば、search paramsの型をそこまで気にする必要がなくなるのかなと思いました。search paramsのkeyのズレの懸念は、結局直URLの問題は解消できず、フロントコード内であってもsearch paramsを生成するメソッドを通すようにしたらある程度は防げると思います。

終わりに

以上がTanStack Routerの型安全なsearch paramsを触ってみた所感でした。隅々までsearch paramsの型が当たるというのはとても良い反面、設定の手間さと直URLでどうしてもすり抜けてしまうケースが存在することを考えると、そのコストを払ってでもやるべきことかは微妙なラインな印象を持ちました。今回の例がそもそもLinkで設定したいというより、状態をURLに含めておきたいだけというものが多く、オプショナルであることから尚更型の厳密性がそこまで重要でなかったというのもありそうです。requiredなsearch paramsであれば話は変わってきそうかなと思いました🤔

結局のところ、search paramsをtype-safeにしたとしてもすり抜けてしまう可能性を考慮したり、考えることはまだ残るんだなと思いました。ただ最終的にはtype-safeであることに損はないので引き続きsearch paramsの運用方向を考えていきたいなと思いました💪

GitHubで編集を提案

Discussion