TanStack RouterでChakra UIのTabsをaタグとしてURLと同期させる
こんにちは。株式会社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.
他の記事でのやり方
前提として、Chakra UI (v2) の公式の Tabs には、リンクや a タグに対する言及は特になく、「制御したい場合は onChange と index を使いましょう」程度です。
(なお v3 ではちゃんと説明されていました…… 😭)
Chakra UI (v2) 自体も Link コンポーネント を用意していたりするのですが……
ということで他の記事を検索してみると、以下の記事は見つかりました。
参考にはさせていただきましたが、以下の点で今回の要件は満たしていませんでした。
- next.js 前提である
- onchange を使っている
- 2021年5月 = 調査当時から見ても3年以上前と、やや古い記事
-
Next.js 13 (2022年10月) でも Link コンポーネントの仕様が結構変わってる?
参考: Next.js 13 から next/link の Linkコンポーネント が 常に aタグをレンダリングするようになったので共有します
-
Next.js 13 (2022年10月) でも Link コンポーネントの仕様が結構変わってる?
今回のやり方
結論は冒頭に記載したように、以下となります。
- Tabs → URL は @tanstack/react-router の Link を Tab の as に指定する
- URL → Tabs はパスパラメータから特定したタブを、Tabs の index に指定する
- path にタブ部分の指定がない場合もカバーしたいなら index.tsx で Navigate を使って自動ナビゲーションさせる
- その際に存在しないタブを指定するとタブの変更に強くなる
詳細については以下の仕様で説明していきます。
※実際の機能とは変えています
/settings/ :設定ページ
- appearance:外観タブ
- notifications:通知タブ
- advanced:詳細タブ
URL と同期させる前のコード
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 形式でも同じことができると思います
Tabs → URL の同期
@tanstack/react-router の Link (別名 RouterLink として import ) を Tab の as に指定し、Link に遷移に必要な属性も追加します。
@@ -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 に指定します。
@@ -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 を使用します。
@@ -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 の方で適切なタブにフォールバックしてもらうことにしました。
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 を使うことで実現させていました。
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の話を一度聞いてみませんか?

国民と政治・行政が政策を共に創る社会を。政策共創プラットフォームを運営する株式会社PoliPoli(ポリポリ)の公式開発ブログです。公式サイト: polipoli.work
Discussion