🤨

App Routerの落とし穴 二重サイドバーとルートグループ編 [Next.js]

2023/05/20に公開


今回解説する落とし穴は「二重サイドバー」

複数回に渡って、Next.jsのApp Routerの「落とし穴」を解説する。

前回 に引き続き、コンポーネント設計に関するミスを見ていこう。今回は レイアウトの継承ミス について、細かく解説していく。

問題が発生する状況

下記のようなブログを作るとする。

/ (トップページ)
└── articles (記事一覧ページ)
   └── [articleId] (記事ページ)

トップページを作ろう。app/layout.tsxにレイアウトを書いて...

@/app/layout.tsx
import './globals.css';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body className="min-h-screen flex flex-col bg-gray-200">
        <header className="p-3 text-center bg-yellow-200">
          すごい!ホームページ
        </header>
        <div className="container mx-auto flex flex-col flex-grow md:flex-row bg-white">
          <aside className="p-3 bg-blue-300">ここにサイドバー</aside>
          <main className="p-3 flex-grow">{children}</main>
        </div>
        <footer className="p-3 bg-black text-white text-center">
          フッター
        </footer>
      </body>
    </html>
  );
}

サイドバーを置けば共通化できるな。

@/app/page.tsx
export default async function Home() {
  return <div>トップページ</div>;
}

app/page.tsx はこれで大丈夫かな?

記事一覧ページを作ろう

src/app
+ ├── articles
+ │   ├── layout.tsx
+ │   └── page.tsx
├── layout.tsx
└── page.tsx
mkdir src/app/articles
touch src/app/articles/layout.tsx
touch src/app/articles/page.tsx

/articles (記事一覧ページ)も作ってみよう。

@/app/articles/layout.tsx
export default function ArticleIndexLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex flex-col flex-grow md:flex-row">
      <aside className="p-3 bg-blue-300">
        <h2>記事一覧</h2>
      </aside>
      <main className="p-3 flex-grow bg-white">{children}</main>
    </div>
  );
}
@/app/articles/page.tsx
export default function ArticleIndex() {
  return <div>ここに記事一覧</div>;
}

ヨシ!これでいいかな?

二重サイドバー

あれ? サイドバーが2重になっちゃった!

何が間違っていたのか

説明用にレイアウトの範囲を示した

説明用にレイアウトの範囲を示した。「レイアウトの継承」がうまくいってない 事がわかる。

src/app
├── articles
│   ├── layout.tsx ✅ ◀ 1. 記事一覧ページのレイアウト
│   └── page.tsx
├── layout.tsx ✅ ◀ 2. トップページのレイアウト (継承)
└── page.tsx

これは、App Routerのレイアウト継承順を表している。

記事一覧の親ディレクトリに layout.tsx があるせいで、どちらも使われてしまうのだ。


上記の問題に遭遇し、「継承を諦めて、それぞれの page.tsx にレイアウトを書く」ような人もいるかもしれない。だが、それじゃApp Routerを活かせていない!

ちゃんと解決策はあるから、落ち着いて聞いてほしい。

1. 何を実現したいのかリストアップする

二重サイドバーズームイン

今回達成したかった目的は、以下である。

  1. トップページ(/)と記事一覧がある
  2. トップページ(/)と記事一覧(/articles)で、ヘッダーとフッターは同じ
  3. トップページ(/)と記事一覧(/articles)で、サイドバーの中身を変える

2. トップページのlayout.tsxの記述は必要最小限にする

「ヘッダーとフッターは同じ」だが「サイドバーの中身を変える」必要がある。

@/app/layout.tsx
import './globals.css';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body className="min-h-screen flex flex-col bg-gray-200">
        <header className="p-3 text-center bg-yellow-200">
          すごい!ホームページ
        </header>
        <div className="container mx-auto flex flex-col flex-grow md:flex-row bg-white">
-          <aside className="p-3 bg-blue-300">ここにサイドバー</aside>
-          <main className="p-3 flex-grow">{children}</main>
+          {children}
        </div>
        <footer className="p-3 bg-black text-white text-center">
          フッター
        </footer>
      </body>
    </html>
  );
}

そこで、app/layout.tsxから、ヘッダーとフッターは残しつつ、サイドバーを消す。

app/layout.tsx には、サイト全体で絶対変わらないものだけを記述する。 このレイアウトから逃れることはできないからだ。

@/app/page.tsx
export default async function Home() {
-  return <div>トップページ</div>;
+  return (
+    <>
+      <aside className="p-3 bg-blue-300">ここにサイドバー</aside>
+      <div>トップページ</div>
+    </>
+  );
}

app/page.tsx に、トップページ用のサイドバーを移動するのを忘れずに。

3. 継承してほしくないレイアウトをルートグループで囲む

ちょっと待った! 一覧があるということは、「その下の階層」も考える必要がある。

目次があるレイアウト

例えば、/articles/[articleId] (記事ページ)を作り、「サイドバーに目次が出る」zenn風レイアウトを追加したとする。

src/app
├── articles
+│   ├── [articleId]
+│   │   ├── layout.tsx ✅ ◀ 1. 記事ページのレイアウト
+│   │   └── page.tsx
│   ├── layout.tsx ✅ ◀ 2. 記事一覧ページのレイアウト (継承)
│   └── page.tsx
├── layout.tsx ✅ ◀ 3. トップページのレイアウト (継承)
└── page.tsx

また二重サイドバーに

このままでは、記事ページが「記事一覧」のレイアウトを継承し、また二重サイドバーになる!

目的に立ち返る

いよいよ詰んだか? いや、目的をはっきりさせよう。

  • 記事ページに、目次を出したい
    • ⇔ 記事ページが「記事一覧」のレイアウトを継承しない
      • 「記事一覧」のレイアウトが、記事ページの親でない
        • 「記事一覧」のレイアウトの階層が、下にズレている

階層をずらす? そんな事が可能なのか?

必殺!ルートグループ

そこで、記事一覧を「ルートグループ」で囲んでみよう。

https://nextjs.org/docs/app/building-your-application/routing/route-groups

「ルートグループ」 とは、半角カッコでディレクトリを作って、囲んだルート群のこと。

mkdir "src/app/articles/(list)"
mv src/app/articles/*.tsx "src/app/articles/(list)/"

一覧の articles/*.tsxarticles/(list)/ に入れてみよう。

src/app
├── articles
+│   ├── (list) ⭐ ◀ これがルートグループ
+│   │   ├── layout.tsx
+│   │   └── page.tsx
│   └── [articleId]
│       ├── layout.tsx
│       └── page.tsx
-│   ├── layout.tsx
-│   └── page.tsx
├── layout.tsx
└── page.tsx

すると、こんな構成になる。正直気持ち悪いかもしれないが、App Routerにおけるパズルを解くために、必要な配置なのだ。 何が起こるかというと...

src/app
├── articles
│   ├── (list) ⭐
│   │   ├── layout.tsx ❌ ◀ 記事ページでは無視される
│   │   └── page.tsx
│   └── [articleId]
│       ├── layout.tsx ✅ ◀ 1. 記事ページのレイアウト
│       └── page.tsx
├── layout.tsx ✅ ◀ 2. トップページのレイアウト
└── page.tsx

グループで囲めば「親」ではなくなるため、継承順から無視される。

記事のページを開いても、一覧のサイドバーが混ざることはなくなった!

まとめ

App Routerで「二重サイドバー」に悩んだら、

  1. 何を実現したいのかリストアップする
  2. トップページのlayout.tsxの記述は必要最小限にする
  3. 継承してほしくないレイアウトをルートグループで囲む

の3つを実行しよう。

その他の落とし穴

https://zenn.dev/temasaguru/articles/546f0fcdd9d131

https://zenn.dev/temasaguru/articles/0191cf919bd0a3

https://zenn.dev/temasaguru/articles/6e6b47a34d9855

Discussion