App Routerの最適なデザインパターンを考えてみた
はじめに
Next.js v13 から導入された App Router に、せっかくなので Page Router から移行してみました。
どうも単純な移行とはいかなかったので、今後のバージョンアップにも耐えられる最適なデザインパターン を考えてみました。
App Router については、 公式サイトをご覧ください。
従来、Page Router では、Container/Presenter パターンで実装していました。
これにより、コンポーネントの再利用性、可読性を高めることができました。
ところが、App Router で このパターンをそのまま利用すると
App Router の恩恵を受けることができないので、どう実装するかが課題になります。
Container/Presenter パターン
まずは、サンプルのソースコードを見てください。(import は省略)
const index: NextPage = () => (
<MyLayout>
<SampleContainer/>
</MyLayout>
);
export default index;
export const SampleContainer = () => {
const [val, setVal] = useState<string>("");
return <SamplePresenter val={val} setVal={setVal} />;
};
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 を適用すると、以下のようになります。
const Layout = () => <MyLayout />; // ①
export default Layout;
const Page = () => <SampleContainer />; // ②
export default Page;
"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>
も含まれているため、全量がレンダリングされます。このサンプルだと、少量なのでそこまで問題にはなりませんが、ページの規模が大きくなると、無駄なレンダリングをすることになります。
そこで、以下のモチベーションで、綺麗な分割ができないか考えてみました。
- ロジック部分とビュー部分の分離を維持する
- Client/Server の棲み分けを明確にする
- 再利用しやすい構造にする
その結果、以下のような構成に辿り着きました。
// 変更なし
const Layout = () => <MyLayout />;
export default Layout;
// 変更なし
const Page = () => <SampleContainer />;
export default Page;
// 静的な部分を抽出し、レイアウトとして切り出す。
// 再利用するために、 「=>」 以下を別コンポーネントにしてもよい。
const Layout = ({ children }: { children: ReactNode }) => (
<div>
<h1>Sample</h1>
<div>{children}</div>
</div>
);
export default Layout;
// 変更なし
"use client";
export const SampleContainer = () => {
const [val, setVal] = useState<string>("");
return <SamplePresenter val={val} setVal={setVal} />;
};
// 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 に合わせた、現時点での最適例 として参考にしていただければと思います。
仕様変更についても執筆しているので、よろしければご覧ください。
フィシルコムのテックブログです。マーケティングSaaSを開発しています。 マイクロサービス・AWS・NextJS・Golang・GraphQLに関する発信が多めです。 カジュアル面談はこちら(ficilcom.notion.site/bbceed45c3e8471691ee4076250cd4b1)から
Discussion