Next.js の App Router を考える
Next.js の App Router を使いこなしたいので概念を理解したいので調べたことを雑にまとめていく
調べること
- RSC と RCC を使い分け
- Server Actions
- キャッシュ
- Next.js の用意しているディレクトリ構造の理解
- ディレクトリ構造はどういう形が良いか
RSC と RCC を使い分け
この記事がとてもわかりやすかった。
従来の PHP などのようにサーバー側の処理で HTML を作ってそれをクライアント側で実行するというのがとても自然な理解だった。
確かに JS 側に PHP で計算した結果を無理やり渡してクライアント側で処理をしたりとかやっていたのでそれが React の世界で同じ環境でかけるというのは慣れたらとても書きやすい気がしてきた。
この記事を読む前に色々自分で試しながら使っていていいなーと思ったのは、今まで useEffect
でデータ取得をしていたコンポーネントをラップするような RSC なコンポーネントを作って、それでデータを取得して Props で渡すだけというのが良さそうかなと思った。
単純に useEffect
+ fetch
の箇所だけを RSC に変えるというイメージ
useEffect で fetchしているコンポーネント
よくある今までのデータ取得の形はこんな感じ
function TodoList() {
const [todoList, setTodoList] = useState([]);
useEffect(() => {
async () => {
const data = await fetch('/api/todo');
setTodoList(data.json());
}
}, [todoList]);
return (
<>
{todos.length > 0 && todoList.map((todo) => (
<TodoListItem todo={todo} />
))}
</>
);
}
hooks に処理を分割する
今までは hooks に処理を分割してこんな感じだったと思う
function useTodo() {
const [todoList, setTodoList] = useState([]);
useEffect(() => {
async () => {
const data = await fetch('/api/todo');
setTodoList(data.json());
}
}, [todoList]);
return { todoList, setTodoList };
}
function TodoList() {
const { todoList } = useTodo();
return (
<>
{todos.length > 0 && todoList.map((todo) => (
<TodoListItem todo={todo} />
))}
</>
);
}
RSC に置き換える
データ取得の部分を RSC に移して、サーバー側で取得したデータを RCC の引数に渡して動かす。
// app/api/todo/route.ts
async function GET() {
// DBに接続して todo を取得して返す関数
}
// libなどでAPIを叩く用の関数を定義しておく
async function getTodoList() {
const data = await fetch('/api/todo');
return data.json();
}
// React Server Components
async function TodoListServer() {
const todoList = await getTodoList();
return (
<TodoList todoList={todoList} />
);
}
// React Client Components
function TodoList({ todoList }) {
return (
<>
{todos.length > 0 && todoList.map((todo) => (
<TodoListItem todo={todo} />
))}
</>
);
}
これで何が嬉しいかというと、今までフロント側でデータを取得して setTodoList で値をセットするまでの何も表示されていない時間がなくなるということ。
実際には useSWR とかを使っていい感じにローディングを出したりしていたと思うけど、ユーザーとしてはどうしてもチラつきにつながっていたと思うので、初期表示時のデータ取得をサーバー側でやることでこれらがなくなりユーザーの体験がよくなるはず。
あと、上記の例だと実際には RCC は不要で、そのまま RSC で描画して問題ない。
RCC を使うのは状態を管理してユーザーの操作でそれらが変更される時だけで良いはず。
RSC + RCC + hooks
もし実際に RCC を使って状態を管理してユーザー操作による状態更新を行うとしたらやっぱり hooks に処理を分割した方が良いのかな
// React Server Components
async function TodoListServer() {
const todoList = await getTodoList();
return (
<TodoList todoList={todoList} />
);
}
// fetch の部分は消せるので、引数でデータを受け取ってユーザー操作向けの関数だけ生やす
function useTodo({ todoList }) {
const [todoList, setTodoList] = useState(todoList);
function done() {
// todoを完了にする
}
return { todos, done };
}
// React Client Components
function TodoList({ todoList }) {
const { todos, done } = useTodo(todoList);
return (
<>
{todos.length > 0 && todos.map((todo) => (
<TodoListItem todo={todo} />
))}
</>
);
}
ふと気になったのは、done した時に API 通信をしてデータが更新された時にどうするべきなのか。
今までならフロントの値を更新してそれを元にAPI側も同じ修正をするような感じだったと思うけど、同じ感じでいいのかな。
もし画面遷移せずにデータを再取得したい時は RSC のレンダリングをもう一度走らせるような感じなのかな?
まとめ
まだわからないこともありますが、現状の自分の理解している RSC の使い所はこんな感じ
- いままで
useEffect
+fetch
でデータを取得していた部分を RSC に置き換えて、サーバー側で値を取得し、RCC や hooks に渡して使う - 静的なページやコンポーネント
所感
App Router になったらなるべく RSC にしなくちゃ!という気持ちが強くあったけど、そうではなくガンガン RCC を使っても良いと思えたので気が楽になった。
基本的にはデータ取得の部分だけ RSC に置き換えて、状態管理が絡むところは今まで通り RCC にするので良さそう。
まだ考え中だけどデータ取得をするのが page だと仮定すると、基本的には page だけ RSC にして、そこで取得した情報を RCC に流して使う様な分け方がシンプルなのかも?
RSC の中で RCC は使えるけど、 RCC の中で RSC は使えないということらしいのと、実際に自分で触ってみたときも RSC と RCC をごちゃ混ぜにして色々触ってた時はとにかく混乱したので、ある程度のルールとか使い分けはしておいた方が良い気がする。
疑問
そのファイルが外から見て RSC なのか RCC なのかわからない
- ファイルを開かないとわからない(
'use client'
があるかどうか) - ファイル名や配置するディレクトリでルールを作る?
- わからなくても勝手にいい感じにしてくれるから気にしなくて良い?
- 下に書いた実行環境によって使える関数と使えない関数があるというところでエラーが出ちゃう印象がある
-
getServerSideProps
とかでサーバーで処理をしていた時はそこは明確に分けられていた印象- 範囲が小さかったからかな
- 慣れの問題かも?
外出しした関数など、サーバーでしか動かないものとクライアントでしか動かないものをどうやって分けるのか
-
next/navigation
のcookie
やheader
など、サーバーでしか動かない -
localStorage
などの WebAPI はフロントでしか動かない - これらを使った関数を動かない環境で実行するとエラーになる
- フロントだったらという条件を入れれば一応エラーは回避できそうだが、全ての関数でその考慮をするのはとても大変(
window !== undefined
とか?) - ファイル名やディレクトリ名で分ける?
common
server
client
コンテナ・プレゼンテーションパターン
RSC はもしかしたらコンテナコンポーネントとしての運用が良さそうかな?と思った。
個人的には hooks があるのであんまり意識せずにロジックとビューを分離していたけど、新ためてそこを分離するのはありなのかなと
- Container
- RSC としてデータをフェッチしてプレゼンテーションに渡す
- Presentational
- コンテナから受け取った props を元に描画するだけ
- UIとしての状態がない場合は RSC とする
- UIとしての状態がある場合は hooks を用いて状態を管理し、RCC とする
- hooks
- UIの状態管理のロジックを書く
- データの取得などはなるべく書かないでコンテナから受け取った値を初期値にして動作させる
今までの hooks でデータを取得していた部分をコンテナコンポーネント(RSC)に移す様な感じ
仮)ディレクトリ構造案
features
は切りたい
hooks も上記のコンポーネントに紐づけておいておいた方がいい気がするので特にディレクトリは分けていない
- features
- todo
- model
- Todo.ts
- application
- getTodo.ts
- components
- TodoList
- Container.tsx
- Presenter.tsx
- useTodoList.ts
- test.ts
- stories.tsx
- index.ts(インポートをまとめるためのファイル)
- TodoList
- model
- todo
Server Actions
form の action 処理をサーバーサイドで実行することができる関数。
これをクライアント側の <form> のところに記述することができる。
クライアント側に記述した処理で一部だけサーバー側の処理ですよーってなってるのは気持ち悪い感じもするけど、 getServerSideProps
的なイメージだと思うと、なんとなく納得もできる。
何がいいのかなって思ったけど、もしかしたらクライアント側とサーバー側での二重のバリデーションが不要になるのかな?
今まではクライアント側でも値の検証をして、適切ではない値の場合にはフィードバッグをしていたけど、結局サーバーサイドでもバリデーションしてるしなぁという気持ちもすこしだけあった。
これをサーバー側に寄せることによって、クライアント側はブラウザが提供している required
とかだけにして、ちゃんとしたバリデーションはサーバーサイドだけに実装するとかができるのかも。
でも、別に従来の方法でもクライアント側のバリデーションを止めるだけで同じ状況になるから違うか...
参考
- jsが動かなくてもformだから送信ができる
- レンダリング回数が抑えられる
- App Router のキャッシュ
思ったことメモ
色々調べてる感じ、App Router や React Server Components とかはユーザー体験をよくするための機能とかがいっぱい増えている感じがする。なので、開発者体験が良くなったというよりはより良いものを作るために色々機能が増えている様な印象。
useEffect ではなくサーバーでデータを作ってレンダリングしてから返すことでページのチラつきは無くなる。これはとても気になっていたので良いなという気がする。
でも Suspend とか使ってコンポーネントごとに段階的に読み込まれるやつは慣れていないユーザー的にはガタガタページが作られて体感が遅く感じる気もする。
前職でも表示速度を上げるためにコンテンツ以外のレイアウトを先に表示をして、コンテンツ部分だけ遅延で読み込んでた時に遅いって言われたことがある。従来の全て準備ができてからクライアントに渡して描画の方が早く感じることは結構ある気がしていて、中途半端な状態を早めに返すことは必ずしも良いことだと思わない。多分従来の読み込みが終わるまで真っ白なのは慣れているけど、画面自体は表示できているのにコンテンツ部分だけローディングとかを出すとより待ってる感が強まるのかも(実際の秒数としても全部読み込んで返すのとほぼ変わらないので、ただ段階的に読み込まれることがチラつきに感じる)
YouTube とかみたいに大規模でコンテンツも多いサービスとかならこういう少しでも早く動かす努力は必要な気がするけど、普段仕事で作っているようなアプリケーションだと細かなチューニングの前にもっとDB周りとかキャッシュ周りでやれることが結構ある気がしていて、機能を全て使って多機能にしていくのは少し考えた方が良い気もしている。
Server Actions について思ったこと
試しに使ってみたけど全然理解できていない。
上記ではコンテナ・プレゼンテーションパターンがいいのではということを書いたが、server actions で API を叩こうとする場合、結局サーバーコンポーネントでないといけないのでややこしいことになった。
コンテナ(RSC)→プレゼンテーション(RCC)→フォーム(RSC)という感じで結局フォーム用のRSCを作ることになった。そしてフォームをRSCで動かすためにプレゼンテーションを RSC にする必要があって結局 RCC は挟まない感じになった。
今までみたいにインタラクティブな操作の延長で送信させたい時はどうしたらいいんだろう...
チェックボックスのチェックをつけたり外したりした時に都度送信したい時とか
server actions を使うなら RSC にするしかないし、RCC でフォームを扱うなら server actions を使わずに今まで通りに書くのかな。その場合は useEffect とかで状態の変更を検知して API を叩く感じでいいのかな(でも cookies() とか使いたいんだよな)
一旦、useFromState とかの hooks があるみたいなのでそれを調べてみる
データ取得はサーバー側とクライアント側の2種類に分けて考える
このスライドのここら辺がいいなーと思った。
少し自分なりの解釈を足すとこんな感じ
- サーバー側
- SSR(初期レンダリング)に反映されて欲しいもの
- サーバーで行う必要があるデータ取得は全部 Server Componnent で行う
- 変更のないもの、SEOを意識して埋め込んでおきたい情報など
- 具体例)ニュース、ブログなどの記事
- クライアント側
- 初期レンダリングに含まれなくて良いもの
- サーバー側とクライアント側でデータの共有をしようとしない
- クライアント側で変更のあるもの
- 具体例)TODOリスト、SPAで作ってた管理画面など
メモ
クライアントからもサーバーからもデータを触るということは DB へのアクセスは全て API で設計しておいて適宜 RSC や RCC から API を呼ぶ形が良さそう?(実際はユースケースなどの関数を介して叩くだろうけど)
ディレクトリ構造案
ためしに features にして、色々層を作ってやってみた
api 側も features 側で書いて、app/api では import するだけにしてみた
まあまあいい感じだったけど似たようなファイルが増えすぎてファイルを探すのがめっちゃ面倒だった。
api の方は場所としては features にまとめられて良さそうだったんだけど、エンドポイントが増えた時にあんまり良い収まり方にならなかったので、素直に app/api 配下にエンドポイントごとに実装するのが良さそうだった
└── src
├── app
│ └── api
│ ├── auth # features/auth/data/api で定義したものを読み込むだけ
│ ├── todo
│ └── user
├── features # 機能ごとのファイルを置く場所、型はそれぞれの model のところにおく
│ ├── auth
│ │ ├── data # api, datamodel
│ │ ├── domain # usecase, model, actions
│ │ └── view # components, hooks, provider
│ └── todo
│ ├── data # api, datamodel
│ ├── domain # usecase, model, actions
│ └── view # components, hooks, provider
├── components # 共通のUIパーツやコンポーネントを置く
│ ├── layouts
│ └── ui
├── hooks # 共通の hooks
├── lib # ライブラリのあれこれ
│ ├── firebase
│ └── zod
├── types # 共通の型
└── utils # 共通の関数など
その他
Xで見かけたこちらも良さそうなのでこれも試してみたい
クライアントとサーバーでどちらからもAPIを叩くことを考慮しようとした時のメモ
元々、全てサーバー側に寄せようと思っていた。データの取得はサーバーで更新も Server Actions を使い、クライアントコンポーネントは最低限にしていた。
ただ、クライアント側での操作が多いところはサーバーから渡されたものをクライアントに渡してそれを状態管理するより、クライアントでデータを取得しちゃった方がいいのか?と思ったのでやろうとした。
その前にサーバーで取得したデータをクライアントコンポーネントのpropsで渡して、そのpropsをuseStateに入れようとしたけど、更新時とかにうまいこと更新されなかった(ここら辺の細かいドキュメント読んでないからきっと方法はあるのかな?)
APIを叩く時にクライアント側だと相対パスでも良いが、バックエンド側で叩くときは絶対パスが必要。加えて cookie の値などを引き回すのもサーバーだとうまいこと行かなかった(ログインをセッションで扱うのでこれは必須)
サーバー側の場合は next/headers
からインポートした cookies
や headers
を使ってAPIを叩いていた。(というかそもそもサーバーから呼ぶだけならAPIにしなくても良い気がしてきた)
元々ユースケース的な関数を用意してそれをコンポーネントから実行していて、このユースケースはサーバー側前提だったので直接 headers
などのインポートをしていた。
クライアント側からもAPIを叩こうとするとこれが動かない。
なので、next/headers
のインポートを動的インポートにして、サーバー側の場合( typeof window === "undefined"
)のみインポートして fetch の引数に設定するラッパー関数を作った。
これを使えばAPIをサーバー側、クライアント側から叩くことができた。(クライアントから叩くならやっぱりAPIで良さそう)
動的インポートはたくさん叩くとパフォーマンス的に気になるけど、GPTに聞いたところサーバー側でのみ読み込まれるということならそこまで影響ないっぽいという感じだった(ちゃんとは調べてない)
クライアント側からだと実際は useSWR とか使いたいけど、せっかくユースケース関数を作ったけどどうするのがいいんだろう...