LoginSignup
126
66

Next.js ver13のappディレクトリをなんとなく批判したいので、酔った勢いで敵情を調査してみた

Last updated at Posted at 2023-05-31

はじめに

ふぁるです。
フロントエンドを実装するエンジニアとしてなんやかんや生きております。

今回は、2022年10月にリリースされたNext.js 13系について兼ねてより興味があったのと、ここ最近超絶久しぶりに個人開発のモチベーションが爆熱してきた事から、平日真夜中のそのそとビール片手に話題のappディレクトリについて調査を行い、その勢いで記事を執筆しました。
最後まで読んでいただければ幸いに存じます。

書くこと、書かないこと

  • 書くこと
    • 従来のルーティングとappディレクトリによるものとの違い
    • appディレクトリを採用した開発で必要となる基本概念
    • ParallelRoute, InterceptingRouteのあれこれ
  • 書かないこと
    • JavaScript, TypeScript, React等の基本的な構文

使用される用語

  • ツリー:階層構造を視覚化するための規則。たとえば、親コンポーネントと子コンポーネントを含むコンポーネント ツリー、フォルダー構造などです。
  • サブツリー:新しいルート (最初) で始まり、葉 (最後) で終わるツリーの一部。
  • ルート:ルート レイアウトなど、ツリーまたはサブツリー内の最初のノード。
  • リーフ:URL パスの最後のセグメントなど、子のないサブツリー内のノード。

URL の構造に関する用語

  • URL セグメント:スラッシュで区切られた URL パスの一部。
  • URL パス:ドメインの後に続く URL の一部 (セグメントで構成されます)。

appディレクトリについて

In version 13, Next.js introduced a new App Router built on React Server Components, which supports shared layouts, nested routing, loading states, error handling, and more.
The App Router works in a new directory named app. The app directory works alongside the pages directory to allow for incremental adoption. This allows you to opt some routes of your application into the new behavior while keeping other routes in the pages directory for previous behavior. If your application uses the pages directory, please also see the Pages Router documentation.

Next.jsのバージョン13では、共有レイアウト、ネストされたルーティング、読み込み状態、エラーハンドリングなどをサポートするReact Server Componentsに基づいた新しいApp Routerが導入されました。
App Routerは、新たにappという名前のディレクトリで動作します。appディレクトリは、従来の動作を維持しながら新しい動作に一部のルートを適応させることを可能にするため、pagesディレクトリと並行して動作します。

従来、Next.js, Nuxt.jsではページコンポーネントをpages/ディレクトリで管理していましたが、Next.js13で導入されたappディレクトリパターンは異なるルールを有します。
従来ではpages/以下にコンポーネントを配置した場合、それらはページコンポーネントとして見做されていました(例えば、pages/ディレクトリにabout.tsxを配置したとき、pages/aboutを実装出来る形となっていました)が、appディレクトリではそのようにはならず、
app/about/page.tsxのような配置にする必要があります。→ component-hierarchy

当記事ではappディレクトリの具体的な仕様を公式ドキュメントから調査・気になった箇所を実際に実装し理解を進めつつ、小言を放つ予定です。

基本的な概念

気になったところを、公式ドキュメントの焼き増し的な形で挙げていきます。
ParallelRoute, InterceptRouteについてはこちら

Route Segments(ルートセグメント)

https://nextjs.org/docs/app/building-your-application/routing#route-segments
image.png

ルート内の各フォルダーはルート セグメントを表します。各ルート セグメントは、URL パス内の対応するセグメントにマッピングされます。

フォルダー名に沿ってURLが実現される話ですね。
pagesディレクトリ以下でも同様の仕様かと存じます。

Nested Routes(ネストされたルート)

ネストされたルートを作成するには、フォルダーを相互にネストできます。たとえば、ディレクトリ/dashboard/settings内に 2 つの新しいフォルダーをネストすることによって、新しいルートを追加できますapp。
ルート/dashboard/settingsは 3 つのセグメントで構成されます。
/(ルートセグメント)
dashboard(セグメント)
settings(葉の部分)

従来のNext.jsやNuxt.jsを触っていれば、こちらについても特筆すべき事は無さそうです。

File Conventions(ファイルの規則)

Next.js は、入れ子になったルートで特定の動作を行う UI を作成するための特別なファイルのセットを提供します。
スクリーンショット 2023-06-01 020759.png

「おっ...」てな感じのやつが来ました。
appディレクトリ以下では、ファイル名が意味を持ちます。
layout.(js|jsx|ts|tsx|md|mdx)みたいにした場合は、同階層並び子ルートの共通レイアウトを定義可能です。
例えばvueではlayouts/のようなディレクトリを作成し、ページコンポーネントのlayoutオプションにファイル名を指定する等していましたが、今回はセグメントによる共通レイアウトの制御を行います。
app/に置いたlayoutは強制的にすべてのページで共通レイアウトとして有効になり、app/aboutにlayoutを置いた場合、URLが/about/*となるすべてのページで共通レイアウトとして有効となります。

その他についても同様であり、逆説的にそれ以外のファイル名でコンポーネントを作成しても、ルーティングに影響を与えることは出来ないことになります。
例えば、従来では動いていたpages/posts/[id].tsxみたいなルーティングは不可能となります。
後述するかちょっと(今酔ってるので)わかりませんが、appディレクトリを採用する場合、app/posts/[id]/page.tsxとする必要があります。

Colocation(コロケーション)

https://nextjs.org/docs/app/building-your-application/routing#colocation
image.png
↑で書いた気がします、ファイル名が大事やねんみたいな感じの話な気がします。
colocationについては色々面白い話が多そうなので、次項で幾つか気になった項目を少し深堀します。

Colocation以下

Private Folder(プライベートフォルダ)

https://nextjs.org/docs/app/building-your-application/routing/colocation#private-folders
image.png

ルーティングにおいて、ファイル名が意味を持つみたいな話がありましたが、こちらではルーティングに影響を及ぼさずにフォルダを作成する方法について触れています。
ルーティングに影響を及ぼさないフォルダ(プライベートフォルダ)は_(アンダースコア)を頭につける事で作成可能です。

UI ロジックをルーティング ロジックから分離します。
プロジェクトおよび Next.js エコシステム全体で内部ファイルを一貫して整理します。
コードエディターでのファイルの並べ替えとグループ化。
将来の Next.js ファイル規則との潜在的な名前の競合を回避します

といった思想から_によるプライベート化を実装したらしく、特に「コードエディターでのファイルの並べ替えとグループ化」については軽く開発してみても助かるところが大きく感じました。

こちらの話題では、コンポーネントをどう共通化するか、といった話を一番に連想したので少し書きたいと思います。
一例ですが、私は以下のようにするのを推しています。

https://twitter.com/timneutkens/status/1659313703374712833
スクリーンショット 2023-06-01 023426.png

システム共通のものをapp/_componentsに、ページやルートで共通で使用されるものをページディレクトリと同階層の_components以下に配置するものです。
グローバルなcomponentsディレクトリを特定の機能のためのコンポーネントに汚染させず、コンポーネントが呼び出されるスコープがわかりやすいのが良いなぁと思いました。

Route Groups(ルートグループ)

image.png
これまたちょっと気持ち悪いのが出てきました。
ルーティングに影響を及ぼさず、機能(ページディレクトリ)をグループ化するものです。

app/
  (admin)/
    dashboard/
      detail/
        page.tsx

とした場合でも、実際にはdashboard/detailとしてルーティングされます。
面白いのが、

app/
  (admin)/
    layout.tsx
    dashboard/
      detail/
        page.tsx
    mypage/
      page.tsx

とするとdashboard/*mypage/*に対してlayoutを共通化させることが出来る点ですね。

appディレクトリ以下、高度な概念

見ながらうーんうーん言いました。
結局、ちょっと実際作ってみないとわかんねえなと思い実装してみたのがこちらです。

Parallel Routes(並行ルート)

Parallel Routing allows you to simultaneously or conditionally render one or more pages in the same layout. For highly dynamic sections of an app, such as dashboards and feeds on social sites, Parallel Routing can be used to implement complex routing patterns.
For example, you can simultaneously render the team and analytics pages.

Parallel Routingを使用すると、同じレイアウトで1つまたは複数のページを同時または条件付きでレンダリングすることができます。ダッシュボードやソーシャルサイトのフィードなど、アプリの非常に動的なセクションの場合、Parallel Routingを使用して、複雑なルーティングパターンを実装することができます。
たとえば、チームページと分析ページを同時にレンダリングすることができます。

image.png

なるほどなるほど、Parallel Routingを使用すると、同じレイアウトで1つまたは複数のページを同時または条件付きでレンダリングすることが出来るそうです。

はあ?

image.png

実際にやってみます。

実装してみる

とりあえずなんかそれっぽく、layoutから呼び出す用のページ(@test1,2,3)とparallel/のページ、layoutがネストされる仕様を活用するparallel/nested/page.tsxを作ってみました。

スクリーンショット 2023-06-01 025315.png

それぞれのファイルの内容が以下です。

parallel/layout.tsx
export default function Layout(props: {
  children: React.ReactNode;
  test1: React.ReactNode;
  test2: React.ReactNode;
  test3: React.ReactNode;
}) {
  return (
    <div className="text-center pt-5">
      <h2 className="text-2xl font-bold">Parallel Routes</h2>
      <div>{props.children}</div>
      <div>{props.test1}</div>
      <div>{props.test2}</div>
      <div>{props.test3}</div>
    </div>
  );
}

parallel/page.tsx
import Link from "next/link";
import { Card } from "@/app/_components/Card/Card";

export default function ParallelPage() {
  return (
    <div className="container mx-auto pt-3">
      <h3 className="text-xl font-bold pb-5">top page</h3>
      <Card>
        <div className="">
          <h4 className="text-lg font-bold py-5">
            以下のディレクトリのコンポーネントがここに表示されています。
          </h4>
          <div className="mockup-code">
            <pre>
              <code>/app/(demo)/parallel/default.tsx</code>
            </pre>
          </div>

          <div>
            <h4 className="text-lg font-bold py-3">
              /app(demo)/parallel/nested/page.tsxへのリンク↓
            </h4>
            <Link href="/parallel/nested" className="text-blue-600">
              /app(demo)/parallel/nested/page.tsx
            </Link>
          </div>
        </div>
      </Card>
    </div>
  );
}
parallel/@test1/default.tsx(2,3もほぼ同じなので割愛)
import { Card } from "@/app/_components/Card/Card";

export default function ParallelPageTest1() {
  return (
    <div className="container mx-auto pt-5">
      <h3 className="text-xl font-bold">test1</h3>
      <Card>
        <div className="">
          <h4>以下のディレクトリのコンポーネントがここに表示されています。</h4>
          <div className="mockup-code">
            <pre>
              <code>/app/(demo)/parallel/@test1/default.tsx</code>
              <-- 入れてみたかったのでdaisyui入れました -->
            </pre>
          </div>
        </div>
      </Card>
    </div>
  );
}
parallel/nested/page.tsx
import Link from "next/link";
import { Card } from "@/app/_components/Card/Card";

export default function NestedPage() {
  return (
    <div className="container mx-auto pt-3">
      <h3 className="text-xl font-bold pb-5">nested page</h3>
      <Card>
        <div className="">
          <h4 className="text-lg font-bold py-5">
            以下のディレクトリのコンポーネントがここに表示されています。
          </h4>
          <div className="mockup-code">
            <pre>
              <code>/app/(demo)/parallel/nested/page.tsx</code>
            </pre>
          </div>
        </div>
      </Card>
    </div>
  );
}

画面はこのように表示されます。

スクリーンショット 2023-06-01 025923.png

なるほど確かに、@~~~/default.tsxとかpage.tsxlayoutからじょりっと表示されています。
ちなみにparallel/@test1とかURLを打ち込んでも、404エラーです。

うーん・・・・・・

だから何・・・・・・???

となってしまったので、もうちょっと公式ドキュメント読んでみます。

ルートの独立による、エラーやローディングのハンドリング

image.png

Parallel Routing allows you to define independent error and loading states for each route as they're being streamed in independently.

Parallel Routingでは、各ルートが独立して流れてくるので、独立したエラーやロードの状態を定義することができます。

ほう!

確かに、言われてみるまで気付かなかったのですが、appディレクトリ配下ではerror.tsx, loading.tsx等のファイルを設置することにより、エラー時や、API通信等を行っている最中(Suspence)ローディング時に別のコンポーネントをレンダリングさせる事が出来ます。

@analyticsが共通化されたコンポーネントではなく、ルーティングされるページとして存在する場合、そういったファイルシステムの恩恵を受けることが出来るようです。

@analyticsがローディング中だからスケルトン、一方横に並ぶ@teamのほうはデータ取得が終わってるから表示、みたいなのをファイルシステムに乗っかるだけで実現出来るわけです。

確かに便利ですね!
学習コストたっけえ!

認証状態などの特定の条件に基づいてスロットの表示状態を制御

image.png

また同様に、layoutレベルで認証状態等を判別することで、スロットを出しわける事が出来ます。
ページに認可のロジックを持たせるのではなく、layoutから制御しちゃうなんて事も出来ちゃうわけですね。
便利だとは思いますが、「layoutでそこまでやっちゃうの......?layoutはレイアウトの共通化のためじゃなかったの......?」感が自分としては強いです。

「認可ロジックを持ちページを出しわけても、ページのレイアウトの制御は制御だからlayoutでやるのは妥当」なんでしょうか

Intercepting Routes(傍受ルート...?)

自分、こいつは理解難易度で言えば、appディレクトリ一番の極悪野郎だと思っています。こいつのせいで酔いが覚めました。

Intercepting routes allows you to load a route within the current layout while keeping the context for the current page. This routing paradigm can be useful when you want to "intercept" a certain route to show a different route.
For example, when clicking on a photo from within a feed, a modal overlaying the feed should show up with the photo. In this case, Next.js intercepts the /feed route and "masks" this URL to show /photo/123 instead.

ルートをインターセプトすると、現在のページのコンテキストを維持しながら、現在のレイアウト内にルートを読み込むことができます。このルーティング パラダイムは、特定のルートを「インターセプト」して別のルートを表示する場合に役立ちます。
たとえば、フィード内で写真をクリックすると、フィードをオーバーレイするモーダルが写真とともに表示されるはずです。この場合、Next.js は/feedルートをインターセプトし、この URL を「マスク」して/photo/123代わりに表示します。

image.png

???

何を言っているんでしょうか

ルーティングに介入するということですか

なんでそんな複雑さを悪戯に向上させるようなことを......?

公式からexampleを見つけたので、参考に(ほぼパクリながら)実装再現してみます。

実装してみる

ファイルの構成がこんな感じ
スクリーンショット 2023-06-01 032332.png

コードがこんな感じ

intercepting/default.tsx
export default function Default() {
  return null;
}

intercepting/layout.tsx
export default function Layout(props: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <div className="text-center pt-5">
      <h2 className="text-2xl font-bold">Intercepting Routes</h2>
      {props.children}
      {props.modal}
    </div>
  );
}
intercepting/page.tsx
import Link from "next/link";
import { photos as swagPhotos } from "./constants";
import Image from "next/image";

export default function InterceptingPage() {
  const photos = swagPhotos;

  return (
    <div className="container mx-auto">
      <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-3 auto-rows-max	 gap-6 m-10">
        {photos.map(({ id, imageSrc }) => (
          <Link key={id} href={`/intercepting/photos/${id}`}>
            <Image
              alt=""
              src={imageSrc}
              height={500}
              width={500}
              className="w-full object-cover aspect-square"
            />
          </Link>
        ))}
      </div>
    </div>
  );
}

@modal/(.)photos/[id]/page.tsx
import Image from "next/image";

import { Modal } from "../../../_components/Modal";
import { photos as swagPhotos } from "../../../constants";

export default function PhotoModal({
  params: { id: photoId },
}: {
  params: { id: string };
}) {
  const photos = swagPhotos;
  const photo = photoId && photos.find((p) => p.id === photoId);

  if (!photo) return null;

  return (
    <Modal>
      <Image
        alt=""
        src={photo.imageSrc}
        height={600}
        width={600}
        className="w-full object-cover aspect-square col-span-2"
      />

      <div className="bg-white p-4 px-6">
        <h3>{photo.name}</h3>
        <p>Taken by {photo.username}</p>
      </div>
    </Modal>
  );
}
photos/[id]/page.tsx
import React from "react";
import Image from "next/image";
import { photos as swagPhotos } from "../../constants";

export default function PhotoPage({
  params: { id },
}: {
  params: { id: string };
}) {
  const photo = swagPhotos.find((p) => p.id === id);

  if (!photo) return null;
  return (
    <div className="container mx-auto my-10">
      <div className="w-1/2 mx-auto border border-gray-700">
        <Image
          alt=""
          src={photo.imageSrc}
          height={600}
          width={600}
          className="w-full object-cover aspect-square col-span-2"
        />

        <div className="bg-white p-4 px-6">
          <h3>{photo.name}</h3>
          <p>Taken by {photo.username}</p>
        </div>
      </div>
    </div>
  );
}

intercepting/_components/Modal.tsx
import { useCallback, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";

import { Props } from "./types";

export const Modal = ({ children }: Props) => {
  const overlay = useRef<HTMLDivElement>(null);
  const wrapper = useRef<HTMLDivElement>(null);
  const router = useRouter();

  const onDismiss = useCallback(() => {
    router.back();
  }, [router]);

  const onClick = useCallback(
    (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
      if (e.target === overlay.current || e.target === wrapper.current) {
        onDismiss();
      }
    },
    [onDismiss, overlay, wrapper]
  );

  const onKeyDown = useCallback(
    (e: KeyboardEvent) => {
      if (e.key === "Escape") onDismiss();
    },
    [onDismiss]
  );

  useEffect(() => {
    document.addEventListener("keydown", onKeyDown);
    return () => document.removeEventListener("keydown", onKeyDown);
  }, [onKeyDown]);

  return (
    <div
      ref={overlay}
      className="fixed z-10 left-0 right-0 top-0 bottom-0 mx-auto bg-black/60"
      onClick={onClick}
    >
      <div
        ref={wrapper}
        className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full sm:w-10/12 md:w-8/12 lg:w-1/2 p-6"
      >
        {children}
      </div>
    </div>
  );
};

画面はこうなります。(exampleのコードをtsx化したりはしましたが、レイアウトは完ぺきに同じですね)
スクリーンショット 2023-06-01 033032.png

画像をクリックするとモーダルがにょきっと出ます

スクリーンショット 2023-06-01 033048.png

再読み込みしてみますか。

スクリーンショット 2023-06-01 033257.png

!?

同じURLなのに再読み込みするとまったく違う画面に来たんだけど!?

これは......何......!?

ちょっと自分が何をやったか冷静に考えます。

  • overlayして画面クリックで閉じる、簡単な仕様のモーダルを自家製で作り
  • layoutでparalellルートとして、子ルートとモーダルスロットをレンダリングするよう実装
  • photos/[id]/page.tsxという形で、画像の詳細画面を作った
  • 謎に、@modal/(.)photos/[id]/page.tsxという形で、画像をモーダル表示するだけのぺージを作った
  • @modal/default.tsxも作った。作らないと404だった
  • すると何、一覧画面から画像クリックでは@modal/(.)photos/[id]/page.tsxが上から表示されて(モーダルが表示状態になって)、リロードすると本来のセグメントのページが表示された

一瞬、魔法か?って思いましたが、こう箇条で書くとなんとなく理解出来そうです。

Parallelルートの機構を応用し、childrenとして表示される画面と@modal/*とを常にレンダリング

まず、
layoutでparalellルートとして、子ルートとモーダルスロットをレンダリングするよう実装
について、

/interceptingを表示している場合でも、
/intercepting/photos/2を表示している(モーダルではなく詳細画面が表示されている)でも、
@modalスロットはマウントされます。

これはParallelルートの実装から自明で、表示制御を行わない場合にはスロットはすべて表示されます。

よって@modal/default.tsxがない場合、@modalスロットに挿入され機能するファイルが無いため、404となると......

次に機能するのが、@modal/(.)photos/[id]/page.tsxフォルダー名に付いている(.)ではないかと思います。
調べてみると以下のような記述がありました。

インターセプトルートのセグメントの指定

Intercepting routes can be defined with the (..) convention, which is similar to relative path convention ../ but for segments.
You can use:
(.) to match segments on the same level
(..) to match segments one level above
(..)(..) to match segments two levels above
(...) to match segments from the root app directory
For example, you can intercept the photo segment from within the feed segment by creating a (..)photo directory.

インターセプトルートは、(...)規則で定義できます。これは、相対パス規則 .../ と似ていますが、セグメントに対するものです。
を使うことができます:
(.) は、同じレベルのセグメントとマッチします。
(...) は、1つ上の階層にあるセグメントと一致させます。
(...)(...) は、2つ上の階層にあるセグメントにマッチします。
(...) は、アプリのルートディレクトリにあるセグメントとマッチングします。
たとえば、(...)photo ディレクトリを作成することで、フィードセグメントから photo セグメントを傍受することができます。

image.png

ここまでの推理とドキュメントの内容を見るに、(.), (..)みたいなのをフォルダ名の頭につける事でどのセグメントのルーティングを横取りするかを指定しているのかと思われます。

今回私のプロジェクトのフォルダ構成が以下でした。

app/
  page.tsx
  layout.tsx
  (demo)/
    intercepting/
      _components/
      @modal/(.)photos/[id]/page.tsx
      photos/[id]/page.tsx
      page.tsx
      layout.tsx

(.)photosというのは、セグメントから見て同じレベル、すなわち「/intercepting/photos/idへのルーティングを傍受・キャッチした場合は横取り」という意味になるのではないかと考えられます。

そしてルーティングを横取りしたとき、はじめてレンダリングされるページが@modal/default.tsxから、@modal/(.)photos/[id]/page.tsxに移り、画面上に表示される......と

なるほど、スッキリしました。

使い道について

めちゃくちゃ頑張って頭回した末、仕様の理解が出来て非常に満足しました。

使い道をいくつか考えてみます。

例えば、

一覧から、詳細を表示したい。一覧取得にまあまあかかるので、詳細はモーダルで見たい。かつ、リロードした時や直リンした時は時間のかかる一覧取得はやらないでほしい。

とかでしょうか?

確かに、こう書いてみると、まあまあありそうなユースケースな気がします。

もしくはなんだろう

ブラウザバックでモーダルを閉じるという動作を全システム的に有効化出来るとかもありそうでしょうか?

楽そうです。

公式では、以下のようなものを挙げているようでした。

モーダル コンテンツをURL 経由で共有できるようにする
ページが更新されたときに、モーダルを閉じる代わりにコンテキストを保持します。
前のルートに移動するのではなく、逆方向のナビゲーションでモーダルを閉じます
前方ナビゲーションでモーダルを再度開きます

あとは普通に存在しているログイン画面を商品一覧ページとかににょきっと、表示させるような事も見据えてるようです。

基本的にこんな複雑で面倒な実装は最初批判的だったんですが、こう、仕様を理解出来た上で活用例を示されると思ったより揺らぎました。

使ってみたいし、案外Parallelルート、interceptingルートで救われる命もありそうな気がしてきました。

難しいことを今日はたくさん考えて疲れてしまったので寝ようかと思います。

おわりに

今日はappディレクトリのルーティングについてじっくり見てみました。

面白かったです。

良ければtwitterフォロー、Qiitaのフォロー等よろしくお願いします~

126
66
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
126
66