🪣

[24/3/13 追記]Intercepting Routesで 幸せになった話

2024/01/12に公開

https://nextjs.org/docs/app/building-your-application/routing/intercepting-routes

だいぶ今更感がありますが、 Intercepting Routes を導入してのメリデメをまとめました。結論から言うと、早く導入しておけば良かったなぁという感想です。

本記事を読むにあたり

以下の方々の救えればという思いで記載しています。

  • NextJSの機構理解を膨らませたい方
  • 公式を読んでも、理解や実装で挫折した方
  • モーダルのバケツリレーでうんざりしている方
  • サーバサイドでの処理をうまく活用したい方

モチベーション

フロントエンド開発を進めていく中で、画面遷移のほかに、モーダル表示したいことがあるかと思います。例えば、

  • リストをクリックして、詳細をモーダルで表示したい。
  • リストをクリックして、詳細をモーダル上で編集したい。
  • リストに新規追加するページをモーダルで表示したい。
  • 削除等の処理の確認プロンプトをモーダルで表示したい。

などなど

モーダル自体は、 いくつかのサードパーティ( MantineMaterial UI などなど )がコンポーネントを提供してくれているので、お手頃に実装できるのですが、以下のような課題に直面していました。

  • 呼び出し元からのプロパティのバケツリレーが多い。
  • プロパティが増えすぎて、必要なプロパティか判断するのに、時間を要する。
  • ページコンポーネントとしての可読性が著しく悪いため、メンテしずらい。
  • モーダルは、クライアントサイドレンダリングなので、サーバサイドでの処理ができない。(つまり、性能的に弱い)

などなど

そのファイル、コンポーネントがどういうもので、なにを責務としているかを一目で認識する手段としては、ファイル名、コンポーネント名でしか わかりません。JsDocとかコメント という手段もあるでしょうが、メンテ追いつかなくなったり、読むのが面倒ってこともあり(少なくとも筆者は怠惰)、最適解ではないということにしておきます。
なるべく、可読性よく、拡張性よく、共有コストがかからない方法でできないかというのが私のモチベーションです。私が執筆する記事はだいたいお馴染みのモチベーションですね。

サンプル

今回は、以下のようなアプリを作りたいと思います。

  1. ToDoを閲覧できる。(/todoList)
  2. ToDoのリストの行をクリックすると、詳細ページがモーダル表示される。(/todo/view/[id])
  3. ToDoのリストの行の「編集」をクリックすると、編集ページがモーダル表示される。(/todo/edit/[id])

サンプルコードは以下です。
https://github.com/Ryo-Kgym/zenn-sample/tree/main/app_router_intercepting_routes

今回使用したライブラリは、package.json をご覧ください。
サンプルと本記事のコードと乖離がある場合がございます。ご了承ください。

コードの階層は、以下のようにします。

src
  |- app
    |- todoList
      |- layout.tsx
      |- @modal ### Parallel Routes
        |- default.tsx
        |- (..)todo -> ### Intercepting Routes
          |- view
            |- [id]
              |- page.tsx
          |- edit
            |- [id]
              |- page.tsx
      |- @list ### Parallel Routes
        |- page.tsx
        |- default.tsx
  |- component
    |- aggregation
      |- todo
        |- ToDoList.tsx
        |- ToDoView.tsx
        |- ToDoEdit.tsx
    |- ui
      |- BackModal.tsx

Parallel Routes

Intercepting Routesを使用するための準備です。 modal へ ルーティングすることになるのですが、初期レンダリング時点では、空っぽでいいので、default.tsx を忘れずに作成します。ないと、エラーになります。

src/app/todoList/layout.tsx
import { ReactNode } from "react";

const Layout = ({
  list,
  modal,
}: {
  list: ReactNode; // @list を埋め込む
  modal: ReactNode; // @modal を埋め込む
}) => (
  <div className={"space-y-5"}>
    <div className={"text-3xl"}>ToDo リスト</div>
    <div>{list}</div>
    {modal}
  </div>
);

export default Layout;
default.tsx
const Default = () => null;
export default Default;

Intercepting Routes

(..) は /todoList から一つ上がって、/todo/view/[id] で受け付けるよ って意味です。
(..)todo(.)todo とすると、 /todoList/todo/view/[id] に変更できます。

参考:NEXT.js Intercepting Routes #Convention

まず初めに、呼び出し元であるリストのコンポーネントを実装します。タイトル部分をクリックすると、 /todo/view/[id] へ、編集ボタンをクリックすると、 /todo/edit/[id] へ遷移するようにします。

src/app/todoList/@list/page.tsx
import { ToDoList } from '@/component/aggregation/todo';

export default ToDoList;
src/component/aggregation/todo/ToDoList.tsx
"use client";

import { useRouter } from "next/navigation";

export const ToDoList = () => {
  const { push } = useRouter();

  const todoData = [
    { id: "1", title: "片付け" },
    { id: "2", title: "買い物" },
    { id: "3", title: "振込" },
  ];

  return (
    <div>
      {todoData.map((todo) => (
        <ul
          key={todo.id}
          className={"flex space-x-2 border-b-2 p-2 items-center"}
        >
          <li
            onClick={() => push(`/todo/view/${todo.id}`)}
            className={"cursor-pointer flex-1"}
          >
            {todo.title}
          </li>
          <button
            onClick={() => push(`/todo/edit/${todo.id}`)}
            className={"w-32 bg-blue-300 rounded-md p-1"}
          >
            編集
          </button>
        </ul>
      ))}
    </div>
  );
};

実際に、Interceptにしたいpage.tsxは以下のように書きます。フォルダ構成は独特ですが、ファイルの中身はそこまで違和感はないと思います。
今回は、現在我々が開発しているシステムのユースケースに合わせて、詳細ページをモーダル表示することとします。

src/app/todoList/@modal/(..)todo/view/[id]/page.tsx
import { ToDoView } from "@/component/aggregation/todo"; // 中身は割愛しています。
import { BackModal } from "@/component/ui";

const Page = ({ params: { id } }: { params: { id: string } }) => (
  <BackModal>
    <ToDoView id={id} />
  </BackModal>
);

export default Page;

UI ライブラリのModalを使用すると、URLが変わらずにモーダル表示されるイメージをお持ちの方も多いと思いますが、今回は 初期にモーダル表示されるページへのルーティングなので、URLが変更されます。通常のモーダルを閉じるだけですと、URLはそのままになります。
少々手間ですが、閉じると同時にURLを遷移前に戻すような専用のコンポーネントを作成しておきます。

src/component/ui/BackModal.tsx
"use client";

import { Box, Modal } from "@mui/material";
import { useRouter } from "next/navigation";
import { ReactNode, useState } from "react";

const style = {
  ... // 省略
};

export const BackModal = ({ children }: { children: ReactNode }) => {
  const [isOpen, setIsOpen] = useState(true);
  const { back } = useRouter();

  const closeHandler = () => {
    setIsOpen(false);
    back();
  };

  return (
    <Modal open={isOpen} onClose={closeHandler}>
      <Box sx={style}>{children}</Box>
    </Modal>
  );
};

実際に、リストのタイトルをクリックしてみてください。ToDoの詳細ページがモーダルで表示されることが確認できると思います。

通常モーダルで呼び出される場合のコンポーネントは、クライアントサイドで呼び出されるため、同じくクライアントでのレンダリングに限定されます。ですが、Intercepting Routesを使うことで、サーバサイドでの処理も可能になります。具体的には、表示するデータをサーバサイド取得にすることができます。

エラーハンドリング

/todoList から /todo/view/[id] を表示できたとします。

さきほど、モーダルを閉じるときに、URLを戻さないと、そのままになるとお伝えしました。このまま、リロードすると、404に飛んでしまいます。NextがURLを叩く場合は、Intercepting Routes が発火するようですが、それ以外の場合 app/todo/view/[id] を見に行ってしまうので、404に遷移するようです。用途に合わせて、ハンドリングしておきましょう。

例1 呼び出し元に遷移する

src/app/todo/view/[id]/page.tsx
import { redirect } from 'next/navigation';

const Page = () => redirect('/todoList');
export default Page;

例2 そのままのページングを許容する

src/app/todo/view/[id]/page.tsx
import { ToDoView } from '@/component/aggregation/todo';

const Page = ({
    params: { id };
}: {
    params: { id: string; };
}) => <ToDoView id={id}/>;

export default Page;

終わりに

モチベーションにも記載していた問題点ですが、今回の例はだいぶシンプルなものなので、そこまで問題にはならないと思います。ですが、規模が大きくなり、ページが肥大すると、億劫になってきます。如何にメンテコストを下げるかが重要になってくるかと思います。

こう言った、Framework 機構のスキル習得には、当然ながら学習コストを伴いますが、誰でもできる実装を目指すとメンテが困難になるという意見をよく耳にします。継続的なが学習が、保守性向上に繋がると思っているので、試してみるのもよいかと思います。

[24/3/13 追記]

弊社では、Intercepting Routesが勝手がいいということで、Intercepting Routesしたページのデータをサーバサイドで取得するようにしてみました。このサンプルでいうところの ToDoView を以下のような構成のコンポーネントで実装しています。

ToDoView.tsx

export const ToDoView = ({id}: {id: string}) => {
  const {title, memo} = fetchToDo({id}) // どこからかデータを取得

  return (
    <ToDoViewClient
      id={id}
      title={title}
      memo={memo}
    >
  )
}

ToDoViewClient.tsx
'use client';

export const ToDoViewClient = ({
    id,
    title: defaultTitle,
    memo: defaultMemo,
  }:{
    id: string;
    title: string;
    memo: string;
  }) => {
  const [ title, setTitle ] = useState(defaultTitle);
  const [ memo, setMemo ] = useState(defaultMemo);
  const { updateToDo } = useUpdateToDo({id})

  const updateHandler = async () => {
    await updateToDo({
      title,
      memo,
    })
    // 更新後にサーバサイドで取得したデータのキャッシュクリアをしたい
  }

  return (
    <div>
      <div>
        <div>Title</div>
        <input value={title} onChange={(e) => setTitle(e.target.value)}>
      </div>
      <div>
        <div>Memo</div>
        <input value={memo} onChange={(e) => setMemo(e.target.value))}>
      </div>
      <div>
        <button onClick={updateHandler}>更新</button>
      </div>
    </div>
  )
}

データの参照まではまったく問題ないのですが、更新後に、サーバサイドから取得しているデータをキャッシュクリアしたいと思い、

  • next/cache/revalidatePath
  • next/navigation/useRouter().refresh()

を使用したところ、404が発生しました。

調べたところ、6095062213 にもあるように、Issueが立ち上がっているようです。

現時点では、解決策がないので、Intercepting Routesを使用した画面では、以下のようなルールを定めました。

  • サーバサイドでのデータ取得をする場合は、データの参照のみにする。
  • データのCUDを伴う場合は、クライアントでのデータ取得にする。
GitHubで編集を提案
フィシルコム

Discussion