この記事で話すこと
この記事では、Next.js App Router
でServer Component(SC)を上手く分離できなくて悩まれている
方の悩みを解消することを目的として書いています!
(実際ぼくはこのServer Componentを上手く分離できず、すごーく悩まされApp Routerはもしかしたらブログとかでしか使えないんじゃないかって諦めかけていました😂)
背景
上記のリンクにて、集合駅検索サービス KokoneにおけるNext.js App Routerのリファクタリングの全体像について書いてみましたが、とんでもない文量になってしまいました。。。
単純に読みにくいかなと思いましたので、今回こちらの記事ではSC/CCの分離に焦点を当てて、書いてみています。
なので、説明している内容は上のリンクと同じです。
"Server Component/Client Componentとは"という部分についてはこの記事では省略していますので、
もしそのあたりからおさらいしたい方は上記の記事を読んで頂ければと思います!
Sever Component(SC)とClient Component(CC)の分離
SCとCCの特徴や制約について、色々あるかと思いますが、
- SCはサーバー上で動くステートレス(状態を持たない)なコンポーネント
- CCはブラウザ上で動くステートフル(状態を持つ)なコンポーネント
という説明が、個人的にはしっくりきています。
しかし、実際にApp Routerを使ってみた方はご存知かと思いますが、このSCとCCは単純に分離できるものでもありません。。。
Client Component(CC)で定義された子コンポーネントには、Sever Component(SC)を読み込むことができないというやっかいな制約があります。
つまり、AppRouterには明確な境界があり、一度CCを親に定義してしまうと、本来はSCで記述したいコンポーネントであっても、SCで定義することが不可能になってしまいます。更に言うと、SC→CCはPropsを介してデータを受け渡すことができますが、CC→SCへはデータやステートを渡すことはできなくなります。
これらの制約により、これまでPages Routerでは、再レンダリングや再利用を考慮したコンポーネント設計が有効でしたが、App Routerではデータ取得と描画遅延範囲などSCとCCの境界を考慮したコンポーネント設計が必要となる様に感じました。
実際のアプリケーションでのSC/CC分離の例
僕たちが作っているKokoneというWEBサービスでは、
今回のリファクタにより、下記の様にSCとCCの分離をしています。(コンポーネントはファイル名でSC or CCが判別できるように、.server.tsx / .client.tsxといったネーミングルールを用いています。)
この分離を進めていく上で、Kokoneでは下記の様な課題に直面しました。
課題①. ステートの伝搬ができないから親コンポーネントをCCにする必要がある。。。
課題②. ステータスを持つコンポーネント(CC)が親コンポーネントにあるからCCにしかできない。。。
これらの課題を解消した方法を下記にそれぞれご紹介します。
課題①の解決策: グローバルステートの活用によるSC/CCの分離
ステートの伝搬ができないから結局親をCCにする必要がある。。。
=> この問題はグローバルステートの導入により解決することができます!
上の図を見て頂いても分かる通り、
App Routerでは大元の親コンポーネントは必ずSC(上図緑枠)となるため、
ステートを持つことができず、SCを上手く活用すればするほどCCコンポーネント間でステート伝搬が難しくなります。
(実際Kokoneでは初期サービス開発時、ヘッダーと検索ドロワー間で共通で持ちたいステートの管理ができなかったため、SCの利用を諦め全てCCで作っていました。。。😂)
この様なステート伝搬問題はグローバルステートを用いることで、解決することができました。
コンポーネントツリーを超えて、CC同士でグローバルステートをやり取りすることで、ステート伝搬に依存せずSCを分離することができます。
App Routerにおけるグローバルステートライブラリ
2024年時点で調べた限りでは、AppRouterで利用されるグローバルステートライブラリは主に下記の3つありました!
- jotai: Atomベースの状態管理ライブラリ
- zustand: Stateベースの状態管理ライブラリ
-
nrstate: SCとCC間でグローバルにステートを共有できるライブラリ
- 一見SC/CC問題を多く解決する様でいいなと思ったのですが、SCはステートレスにするべきというAppRouterの思想に反しており、キャッシュ効率などが悪くするため、利用はやめておきました🙇♂️
それぞれ、ざっくり上記の様な特徴がありますが、Kokoneではzustandを採用しています!
Reduxの様にStoreを作るための複雑な定義は必要なく、簡単にグローバルステートを作成でき、オプション一つでユーザーのローカルストレージと同期させることもできるため、非常に使い勝手がいい様に感じました。
(Atomベース vs Stateベースについては[こちらの記事](Atomベース vs Stateベースについてはこちらの記事が非常に丁寧で分かりやすかったです🙇♂️)が非常に丁寧で分かりやすかったです🙇♂️)
課題②の解決策: Composite Componentパターンの活用によるSC/CCの分離
ステータスを持つコンポーネント(CC)が親コンポーネントにある。。。
=> この問題はComposite Componentパターンを用いることで解決することができます!
図右側にある検索ドロワーはSearchDrawerというコンポーネント内で定義しています。
SearchDrawerは、ユーザーのアクションによりトグルさせたいため、CCでステータスを保持する必要があります。しかし、検索ドロワーのコンテンツ自体は情報を待たずとも、すぐに描画させたいためコンテンツはできればSCとして定義したいという課題がありました。
App Routerでは、Client Component(CC)はServer Component(SC)をimportすることはできませんが、実はComposite Componentというパターンを用いることができます。これは、CCのProps (children)としてSCを受け渡す実装の仕方です。
このパターンを用いることで、ドロワー内のコンテンツをSCに保ったまま、CCでドロワーがオープン/クローズかのステータスを保持することができる様になります。
以上がKokoneにおける、SC分離の課題を解決した方法でした!
ここで紹介した方法はKokoneでのワークアラウンドでしたが、別のベストプラクティスなどをお持ちの方はぜひ教えて頂けますと幸いです!
最後まで見てくださりありがとうございました!!
今後ともより快適なサービスとなる様に、Kokoneの改善に努めていきますので、是非是非応援やフィードバックなど、よろしくお願いいたします!
KokoneのXアカウントでもサービスに関すること(開発/運営/マネタイズ)も発信していたりするので、ぜひフォロー頂ければと思います!
KokoneのXアカウント
https://twitter.com/kokone_official
集合駅検索WEBサービスKokone
https://kokone-app.com?utm_source=zenn&utm_medium=referral&utm_campaign=sc-migration-article-bottom