🤖

App Routerの最適なデザインパターンを考えてみた

2023/08/16に公開

はじめに

Next.js v13 から導入された App Router に、せっかくなので Page Router から移行してみました。

どうも単純な移行とはいかなかったので、今後のバージョンアップにも耐えられる最適なデザインパターン を考えてみました。

App Router については、 公式サイトをご覧ください。

https://nextjs.org/docs

従来、Page Router では、Container/Presenter パターンで実装していました。
これにより、コンポーネントの再利用性、可読性を高めることができました。

ところが、App Router で このパターンをそのまま利用すると
App Router の恩恵を受けることができないので、どう実装するかが課題になります。

Container/Presenter パターン

まずは、サンプルのソースコードを見てください。(import は省略)

pages/sample/index.tsx
const index: NextPage = () => (
    <MyLayout>
        <SampleContainer/>
    </MyLayout>
);

export default index;
pages/sample/SampleContainer.tsx
export const SampleContainer = () => {
  const [val, setVal] = useState<string>("");

  return <SamplePresenter val={val} setVal={setVal} />;
};
pages/sample/SamplePresenter.tsx
export const SamplePresenter = ({ val, setVal }: Props) => (
  <div>
    <h1>Sample</h1>
    <div>
      <TextInput value={val} onChange={setVal} />
    </div>
  </div>
);

pages 配下に index.tsx を配置することで、ルーティングが可能です。
もちろん、index.tsx に Container と Presenter など分けずに記述しても問題ないですが、
ここでは、以下の責務に応じて階層分けします。

  • index: ルーティング
  • Container: ロジック
  • Presenter: ビュー

今回は、3 層構造にしてますが、MyLayout のような共通レイアウトを index から隠蔽するため、 さらに階層を増やしてもよいです。

App Router の導入

App Router を適用すると、以下のようになります。

app/layout.tsx
const Layout = () => <MyLayout />; // ①

export default Layout;
app/sample/page.tsx
const Page = () => <SampleContainer />; // ②

export default Page;
app/sample/SampleContainer.tsx
"use client"; // ③

export const SampleContainer = () => {
  const [val, setVal] = useState<string>("");

  return <SamplePresenter val={val} setVal={setVal} />;
};

①:Page Router の場合には、それぞれの index.tsx に実装が必要でしたが、App Router の場合には、app/layout.tsx に実装するだけで、全てのページに適用されます。仮に、sample ページ固有のレイアウトがあれば、app/sample/layout.tsx を実装するだけで実現できます。

②:Page Router の時と同様に、このファイルはルーティングが責務のため、呼び出しのみに留めます。

③:忘れてならないのは、Client コンポーネントと Server コンポーネントの棲み分けです。
この場合、 SampleContainer の中で useState を使っているので、このコンポーネントは Client でレンダリングされます。逆に、useState を使っていないコンポーネントは、Server でレンダリングされます。

改善点

app/sample/SamplePresenter.tsx をよくみると、Client に依存しないタイトル部分 <h1>Sample</h1> も含まれているため、全量がレンダリングされます。このサンプルだと、少量なのでそこまで問題にはなりませんが、ページの規模が大きくなると、無駄なレンダリングをすることになります。

そこで、以下のモチベーションで、綺麗な分割ができないか考えてみました。

  1. ロジック部分とビュー部分の分離を維持する
  2. Client/Server の棲み分けを明確にする
  3. 再利用しやすい構造にする

その結果、以下のような構成に辿り着きました。

app/layout.tsx
// 変更なし
const Layout = () => <MyLayout />;

export default Layout;
app/sample/page.tsx
// 変更なし
const Page = () => <SampleContainer />;

export default Page;
app/sample/layout.tsx
// 静的な部分を抽出し、レイアウトとして切り出す。
// 再利用するために、 「=>」 以下を別コンポーネントにしてもよい。

const Layout = ({ children }: { children: ReactNode }) => (
  <div>
    <h1>Sample</h1>
    <div>{children}</div>
  </div>
);

export default Layout;
app/sample/SampleContainer.tsx
// 変更なし
"use client";

export const SampleContainer = () => {
  const [val, setVal] = useState<string>("");

  return <SamplePresenter val={val} setVal={setVal} />;
};
app/sample/SamplePresenter.tsx
// Client レンダリングに依存するところだけを切り出す。

export const SamplePresenter = ({ val, setVal }: Props) => (
  <TextInput value={val} onChange={setVal} />
);

ファイル数は増えましたが、 app/sample/SampleContainer.tsx 及び、ここから呼び出される app/sample/SamplePresenter.tsx が、Client でレンダリングされるようになりました。

さらに、app/sample/SamplePresenter.tsx は、従来ページのタイトルまで含んでいたので、再利用しにくい状態でしたが、app/sample/layout.tsx に切り出すことで、再利用しやすい構造になりました。
これは、ページのタイトルは、ページが決定するものであり、パーツに相当するコンポーネントが決定するものではないという 責務分割を意識した結果です。

ただ、規模が大きくなり app/sample/SamplePresenter.tsx も Client レンダリングを回避したい場合があるかもしれません。その場合は、Container に対して、Props 経由で Presenter を渡すことも可能です。今回は、簡単なサンプルにするため、割愛します。

  • page: ルーティング
  • layout: ページレイアウト
  • Container: ロジック
  • Presenter: ビュー

まとめ

フロントエンドのフレームワークは、エンジニアの勉強速度が追いつかないくらい、日々進化しています。そのため、フレームワークの変更に追従するために、フレームワークに依存しないコードを書くことが重要です。

今回、考えたデザインパターンはあくまでも、App Router に合わせた、現時点での最適例 として参考にしていただければと思います。

仕様変更についても執筆しているので、よろしければご覧ください。

https://zenn.dev/ficilcom/articles/app_router_registant_to_changes

GitHubで編集を提案
フィシルコム

Discussion