Zenn
🎲

TanStack RouterでChakra UIのTabsをaタグとしてURLと同期させる

2025/03/10に公開

こんにちは。株式会社PoliPoliでエンジニアをしている kitoo です。

今回は、TanStack Router を使用している場合に、Chakra UI の Tabs コンポーネントを a タグとして URL と同期させようとした話を共有します。

結論

  • Tabs → URL は @tanstack/react-router の Link を Tab の as に指定する
  • URL → Tabs はパスパラメータから特定したタブを、Tabs の index に指定する
  • path にタブ部分の指定がない場合もカバーしたいなら index.tsx で Navigate を使って自動ナビゲーションさせる
    • その際に存在しないタブを指定するとタブの変更に強くなる

前提

  • TanStack Router v1
  • Chakra UI v2
    • 2024年10月にv3が出ていますが…… この実装をした時はまだ出ていなかったんです 🙏
  • 社内向け画面
  • 今回の構成での開発は初めて
    • そもそも React 自体初めて

モチベーション

なぜタブの状態と URL を同期させたいかというと、再読み込みでタブが元に戻ってしまったりせず、また特定のタブへ別の場所からリンクできるようになることで、利便性が上がるからです。
また、a タグとして実現したいのは、別タブで開ける、右クリックメニューで URL をコピーできるなど、「a タグとして」の機能がそのまま動作してほしいからです。

ちなみに TanStack Router の公式でも「できるだけ Link (実際の a タグとして生成される) を使うべき」と言っています。

When possible, Link component should be used for navigation, but sometimes you need to navigate imperatively as a result of a side-effect.


https://tanstack.com/router/v1/docs/framework/react/guide/navigation#navigation-api

他の記事でのやり方

前提として、Chakra UI (v2) の公式の Tabs には、リンクや a タグに対する言及は特になく、「制御したい場合は onChange と index を使いましょう」程度です。


https://v2.chakra-ui.com/docs/components/tabs

(なお v3 ではちゃんと説明されていました…… 😭)

Chakra UI (v2) 自体も Link コンポーネント を用意していたりするのですが……

ということで他の記事を検索してみると、以下の記事は見つかりました。


https://zenn.dev/terrierscript/articles/2021-05-04-chakra-ui-next-js-tabs-url

参考にはさせていただきましたが、以下の点で今回の要件は満たしていませんでした。

今回のやり方

結論は冒頭に記載したように、以下となります。

  • Tabs → URL は @tanstack/react-router の Link を Tab の as に指定する
  • URL → Tabs はパスパラメータから特定したタブを、Tabs の index に指定する
  • path にタブ部分の指定がない場合もカバーしたいなら index.tsx で Navigate を使って自動ナビゲーションさせる
    • その際に存在しないタブを指定するとタブの変更に強くなる

詳細については以下の仕様で説明していきます。
※実際の機能とは変えています

/settings/ :設定ページ

  • appearance:外観タブ
  • notifications:通知タブ
  • advanced:詳細タブ
URL と同期させる前のコード
settings.$tab.tsx
import { Box, Container, Heading, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
import { createFileRoute } from '@tanstack/react-router';

function SettingAppearance() {
  return <Box>1.外観の設定タブの中身です</Box>;
}
function SettingNotifications() {
  return <Box>2.通知の設定タブの中身です</Box>;
}
function SettingAdvanced() {
  return <Box>3.詳細の設定タブの中身です</Box>;
}

const tabsConfig = [
  { path: 'appearance', title: '外観', component: SettingAppearance },
  { path: 'notifications', title: '通知', component: SettingNotifications },
  { path: 'advanced', title: '詳細', component: SettingAdvanced },
];

export const Route = createFileRoute('/_layout/settings/$tab')({
  component: Settings,
});

function Settings() {
  return (
    <Container>
      <Heading py={4}>
        設定
      </Heading>
      <Tabs>
        <TabList>
          {tabsConfig.map((tab) => (
            <Tab key={tab.path}>{tab.title}</Tab>
          ))}
        </TabList>
        <TabPanels>
          {tabsConfig.map((tab, index) => (
            <TabPanel key={index}>
              <tab.component />
            </TabPanel>
          ))}
        </TabPanels>
      </Tabs>
    </Container>
  );
}

※Flat Routes 形式で実装していますが、Directory Routes 形式でも同じことができると思います
https://tanstack.com/router/latest/docs/framework/react/routing/file-based-routing

Tabs → URL の同期

@tanstack/react-router の Link (別名 RouterLink として import ) を Tab の as に指定し、Link に遷移に必要な属性も追加します。

settings.$tab.tsx
@@ -1,5 +1,5 @@
 import { Box, Container, Heading, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
-import { createFileRoute } from '@tanstack/react-router';
+import { createFileRoute, Link as RouterLink } from '@tanstack/react-router';
 
 function SettingAppearance() {
   return <Box>1.外観の設定タブの中身です</Box>;
@@ -30,7 +30,9 @@ function Settings() {
       <Tabs>
         <TabList>
           {tabsConfig.map((tab) => (
-            <Tab key={tab.path}>{tab.title}</Tab>
+            <Tab key={tab.path} as={RouterLink} to="/settings/$tab" params={{ tab: tab.path }}>
+              {tab.title}
+            </Tab>
           ))}
         </TabList>
         <TabPanels>

URL → Tabs の同期

これは単純に、パスパラメータからタブの指定部分を取得し、それから該当するタブを特定し、それを Tabs の index に指定します。

settings.$tab.tsx
@@ -22,12 +22,15 @@ export const Route = createFileRoute('/_layout/settings/$tab')({
 });
 
 function Settings() {
+  const { tab: tabPath } = Route.useParams();
+  const tabIndex = tabsConfig.findIndex((tab) => tab.path === tabPath);
+
   return (
     <Container>
       <Heading py={4}>
         設定
       </Heading>
-      <Tabs>
+      <Tabs index={tabIndex}>
         <TabList>
           {tabsConfig.map((tab) => (
             <Tab key={tab.path} as={RouterLink} to="/settings/$tab" params={{ tab: tab.path }}>

存在しない path が指定された場合の対応

存在しない path が指定された場合は、一番前にあるタブにフォールバックさせます。

その際に @tanstack/react-router の Navigate を使用します。

settings.$tab.tsx
@@ -1,5 +1,5 @@
 import { Box, Container, Heading, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
-import { createFileRoute, Link as RouterLink } from '@tanstack/react-router';
+import { createFileRoute, Navigate, Link as RouterLink } from '@tanstack/react-router';
 
 function SettingAppearance() {
   return <Box>1.外観の設定タブの中身です</Box>;
@@ -25,6 +25,10 @@ function Settings() {
   const { tab: tabPath } = Route.useParams();
   const tabIndex = tabsConfig.findIndex((tab) => tab.path === tabPath);
 
+  if (tabIndex === -1) {
+    return <Navigate to="/settings/$tab" params={{ tab: tabsConfig[0].path }} />;
+  }
+
   return (
     <Container>
       <Heading py={4}>

path にタブ部分の指定がない場合もカバーする対応

存在しないタブだけでなく、そもそもパスパラメータのタブの指定部分がない場合 = /settings/ または /settings の場合も、一番前にあるタブにフォールバックさせたいです。

next.js の Optional Catch-all Segments (/shop/[[...slugs]].jsx) 相当のことができれば良かったのですが、TanStack Router では Catch-all Segments 相当の、Splat / Catch-All Routes しかなく、 Splat 部分が空の場合は拾えませんでした……

仕方がないので、別ファイルで対応します。

子ルートには適用させたくないので、ファイル名は、 settings.tsx ではなく settings.index.tsx にしておきます。

Index routes specifically target their parent route when it is matched exactly and no child route is matched.
https://tanstack.com/router/latest/docs/framework/react/routing/routing-concepts#index-routes

ファイルが別になるので、「デフォルトのタブはどれか?」という情報を分散させるのも、別ファイルにあるタブの情報を直接参照するのもどうかと思い、存在することがないであろうタブを指定することで、settings.$tabs.tsx の方で適切なタブにフォールバックしてもらうことにしました。

settings.index.tsx
import { createFileRoute, Navigate } from '@tanstack/react-router';

export const Route = createFileRoute('/_layout/settings/')({
  // 実際のタブと一致させないことでデフォルトのタブを表示させる(タブ側の実装で対応)
  component: () => <Navigate to="/settings/$tab" params={{ tab: '_' }} />,
});

※なお、Navigate を使って遷移させる場合は settings.tsx を使っても意図通りの動作はしました。

Navigate ではなく redirect でもいい?(苦労したポイント)

最初はページ遷移を component で Navigate を使うのではなく、 loader で redirect を使うことで実現させていました。


https://tanstack.com/router/v1/docs/framework/react/api/router/redirectFunction

settings.index.tsx
import { createFileRoute, redirect } from '@tanstack/react-router';

export const Route = createFileRoute('/_layout/settings')({
  loader: () => {
    redirect({
      to: '/settings/$tab',
      // 実際のタブと一致させないことでデフォルトのタブを表示させる(タブ側の実装で対応)
      params: { tab: '_' },
      throw: true,
    });
  },
});

最終的に動作自体はしましたが、settings.tsx だとうまくいかず、(当然のことながら子ルートにも適用されるため)無限リダイレクトで画面が真っ白の状態になってしまいました。
当初は Index Routes の存在を理解していなかったので、settings.index.tsx を使えばいいということにたどり着くまでに苦労しました……

ただ、そもそも redirect を使ってしまうとアドレスバーに一瞬 /settings/_ が映ってしまいますし、動的なリダイレクトではなく固定の遷移なので Navigate を使う方が良さそうです。

最後に

これでまた世界から a タグになってないページ遷移を少し減らすことができました…… 😉


採用情報

サーバーサイド、クライアントサイド、インフラ、はたまた プロダクトオーナー的な役割等々名目上の役割に関係なくなんでもやりたい・やるぜ!という方は、株式会社PoliPoliの話を一度聞いてみませんか?

https://polipoli.notion.site/PoliPoli-1a62d94ad8ea80c4a903eb2f9e83ccee

https://recruit.jobcan.jp/polipoli-recruit/job_offers/2160352

株式会社PoliPoli

Discussion

ログインするとコメントできます