🗂️

MUI(Material-UI)の Tab コンポーネントと Next.js の next/link を併用する

2023/09/09に公開

MUI(Material-UI) は React や Next.js で使えるマテリアルデザインベースのデザインシステムで、これ自体は非常に優秀なのですが、どうも Next.js の next/link との併用にひと手間かける必要があるそうです。

MUI の Link コンポーネントと Next.js の Link コンポーネントを併用する記事はちらほらあり、私もありがたく参考にしています。

ただ今回は MUI の Tab コンポーネントと Next.js の Link コンポーネントを併用 したく、試行錯誤した結果うまくいったので記事にしてみました。

やりたいこと

Next.js でヘッダー部分に MUI の Tabs コンポーネント を使ってタブを表現しています。

useRouter で URL から collection という変数を取得し、その collection に対応するタブを選択された状態にします。

各タブには next/link によるリンクが設定されていて、例えば「Apple」がクリックされたら、「/Apple/list」というリンク先に遷移します。
(もちろん遷移先にも同じヘッダーが設定されています)

なお、アプリ全体に ThemeProvider コンポーネント が適応されているものとします。

MUI の Tab コンポーネントと Next.js の next/link を併用したサンプルコード

1. リンク内蔵タブのサンプルコード

/components/LinkTab.tsx
import NextLink from "next/link";
import Tab from "@mui/material/Tab";
import React from "react";
import theme from "../lib/theme";

type Props = {
  label: string;
  href: string;
  value?: string;
  selected?: boolean;
};

const LinkTab: React.FC<Props> = ({ label, href, value, selected }) => {
  return (
    <>
      <NextLink href={href} legacyBehavior>
        {selected ? (
          <Tab
            label={label}
            value={value}
            className="Mui-selected"
            sx={{ color: theme.palette.primary.main }}
          />
        ) : (
          <Tab label={label} value={value} />
        )}
      </NextLink>
    </>
  );
};

export default LinkTab;

基本的には Tab コンポーネントを next/link でラップすれば OK なのですが、どうもそれだけだと装飾が崩れるみたいです。

通常、Tab コンポーネントが選択されている場合は、Mui-selected という CSS クラスが設定され、テキストカラーは theme.palette.primary.main で指定された色になります。それらを classNamesx の属性で手動で設定します。

また、next/link 側は そのまま設定するとリンクの中が <a> タグになって装飾が適用されない ため、それを打ち消すために legacyBehavior という属性を設定します。
(これは Next.js の v13 からの仕様です)

2. ヘッダーのサンプルコード

/components/GlobalHeader.tsx
import Paper from "@mui/material/Paper";
import Typography from "@mui/material/Typography";
import Tabs from "@mui/material/Tabs";
import LinkTab from "./LinkTab";

type Props = {
  collection?: string;
};
const GlobalHeader: React.FC<Props> = ({ collection }: Props) => {
  return (
    <>
      <Typography component="header">
        <Paper square>
          <Tabs value={collection || false}>
            <LinkTab label="Apple" href="/Apple/list" value="Apple" />
            <LinkTab label="Grape" href="/Grape/list" value="Grape" />
            <LinkTab label="Banana" href="/Banana/list" value="Banana" />
            <LinkTab label="Orange" href="/Orange/list" value="Orange" />
          </Tabs>
        </Paper>
      </Typography>
    </>
  );
};

export default GlobalHeader;

次にヘッダーのサンプルコードです。

ヘッダーは先述のとおり、useRouter で URL から変数 collection を取得する想定なので、それをパラメータとして受け入れられるようにしています。

Tabs コンポーネントの仕様として、value 属性に入っている値を持っているタブが選択扱いになり、valuefalse の場合は未選択の扱いになります。

なので、パラメータに値が入っていない場合は false を設定します。
(なお、LinkTab 側で設定していた selected パラメータは自動で渡されるので、特に設定しなくても大丈夫です)

3. ページのサンプルコード

/pages/[collection]/list.tsx
import type { NextPage } from "next";
import GlobalHeader from "../../components/GlobalHeader";
import { useRouter } from "next/router";

const ListView: NextPage = () => {
  const router = useRouter();
  const { collection } = router.query;
  return (
    <>
      <GlobalHeader
        collection={
          collection
            ? Array.isArray(collection)
              ? collection.join("")
              : collection
            : undefined
        }
      />
    </>
  );
};

export default ListView;

最後にページのサンプルコードです。

先述のとおり useRouter を使いますが、これで取得できる値は string|string[]|undefined 型なので、三項演算子を使って条件分岐させてヘッダーにパラメータを渡します。

これで Tab と next/link の併用ができました!

おまけ①:外部リンクにも対応する

念のため、外部リンクにも対応しておきます。
外部リンクの場合はそもそも next/link を使う必要がないので、MUI 公式サイトのサンプルの書き方が使えます。

https://mui.com/material-ui/react-tabs/

外部リンクの場合は、URL の先頭が「http」なので、これで条件分岐してあげれば OK です。

また、外部リンクでよくやる対応として、target と rel を設定しておきます。

/components/LinkTab.tsx
import NextLink from "next/link";
import Tab from "@mui/material/Tab";
import React from "react";
import theme from "../lib/theme";

type Props = {
  label: string;
  href: string;
  value?: string;
  selected?: boolean;
};

const LinkTab: React.FC<Props> = ({ label, href, value, selected }) => {
  return (
    <>
+      {href.startsWith("http") ? (
+        <Tab
+          label={label}
+          href={href}
+          component="a"
+          target="_blank"
+          rel="noopener noreferrer"
+        />
+      ) : (
        <NextLink href={href} legacyBehavior>
          {selected ? (
            <Tab
              label={label}
              value={value}
              className="Mui-selected"
              sx={{ color: theme.palette.primary.main }}
            />
          ) : (
            <Tab label={label} value={value} />
          )}
        </NextLink>
+      )}
    </>
  );
};

export default LinkTab;
/components/GlobalHeader.tsx
・・・
<LinkTab label="Google" href="https://www.google.co.jp" />
・・・

これで外部リンクにも対応できました!

おまけ②:App Router に対応する

Next.js の新機能で App Router というものがあり、これを使うとルーティングの方式が変わります。上記のコードは App Router 非対応のものなので、App Router 対応のコードも書いてみます。

LinkTab.tsx

変更なし。

GlobalHeader.tsx

先頭に use client を付与します。

/components/GlobalHeader.tsx
+"use client";

import Paper from "@mui/material/Paper";
import Typography from "@mui/material/Typography";
import Tabs from "@mui/material/Tabs";
import LinkTab from "./LinkTab";

type Props = {
  collection?: string;
};
const GlobalHeader: React.FC<Props> = ({ collection }: Props) => {
  return (
    <>
      <Typography component="header">
        <Paper square>
          <Tabs value={collection || false}>
            <LinkTab label="Apple" href="/Apple/list" value="Apple" />
            <LinkTab label="Grape" href="/Grape/list" value="Grape" />
            <LinkTab label="Banana" href="/Banana/list" value="Banana" />
            <LinkTab label="Orange" href="/Orange/list" value="Orange" />
          </Tabs>
        </Paper>
      </Typography>
    </>
  );
};

export default GlobalHeader;

list.tsx

まずファイルパスとファイル名ですが、/pages/[collection]/list.tsx から /app/[collection]/list/page.tsx に変更します。

その上で以下のように変更します。

/app/[collection]/list/page.tsx
+"use client";

import type { NextPage } from "next";
-import GlobalHeader from "../../components/GlobalHeader";
-import { useRouter } from "next/router";
+import GlobalHeader from "@/components/GlobalHeader";
+import { useParams } from "next/navigation";

const ListView: NextPage = () => {
-  const router = useRouter();
-  const { collection } = router.query;
+  const params = useParams().collection;
+  const collection = params
+    ? Array.isArray(params)
+      ? params.join("")
+      : params
+    : undefined;
  return (
    <>
      <GlobalHeader
        collection={
          collection
-            ? Array.isArray(collection)
-              ? collection.join("")
-              : collection
-            : undefined
        }
      />
    </>
  );
};

export default ListView;

App Router ではページを app フォルダ配下の page.tsx というファイル名で表現するため、そのように変更しています。

また、App Router では useRouter が新しい関数 useParams に置き換わったので、それを使っています。

以上です。

Discussion