Next.jsのApp RouterとReact18の新機能に向き合う

Next.jsの公式ドキュメントを元にやっていく。
【検索からこのスクラップに来た人への注意】
このスクラップでは自分用の断片的なメモです。
Pages Router を一度は触ったことがある前提で進めていきます。
基本的な内容は無視することがありますし、正確性は保証しません。自分なりの解釈の内容も含まれることがあります。解釈違いがあればコメントでご指摘お待ちしております。
最初に覚えておいた方が楽そうなこと
- 【AppRouterはデフォルトでSSRされる】
use client
を使わない限りサーバー側で処理される。

App Router(新しい方) vs Pages Router(従来のやつ)
これまで、 Pages Routerで制作した古いアプリケーションも引き続きサポートされるとのこと。
急いで移行する理由は特に無さそう。
とはいえ、Vercel(Next.js作っている組織)的には 新規プロジェクトでの制作は App Router を推奨している ことは認識しておきたい。
ドキュメントの読み方
Google検索等でNext.jsの公式ドキュメントに辿り着くこともあるでしょう。
その際は必ずパンくずリストを確認するようにしましょう。
先頭部分で「App Router向け」なのか、「Pages Router」向けのドキュメントなのかが書かれている。ここを見落とすと辛いことになるかもなので注意しましょう!

ディレクトリの説明
npx create-next-app@latest
を実行して生成されたフォルダに含まれるディレクトリの説明をします。
app ディレクトリ
既に下記の2ファイルが作られているはずです。
- layout.tsx
- page.tsx
これらのファイルはトップページ /
にアクセスした際に処理され、レンダリングされます。
layout.tsx
app/layout.tsx
には、ルートレイアウトを作成します。ルートレイアウトとは、レンダリングの際に必ず出力されるベースとなるHTMLのこと。
例えば、 app/layout.tsx
の下記の記述。
// 読みづらくなるので型情報は消しました。
export default function RootLayout() {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
)
}
どのページにアクセスしても上記のhtmlタグとbodyタグを出力してくれる指示です。
ベースとなる記述の中に含まれる {children}
がページ毎にコンテンツが出力される場所で、それ以外の記述は共通になるイメージです。
なお、headタグは自動で出力してくれるっぽいので、書く必要はないようです。
page.tsx
次に page.tsx
を見ていきます。
export default function Page() {
return <h1>Hello, Next.js!</h1>
}
これはページ毎のコンテンツ部分です。
layout.tsx
をHTMLのベースとし、layout.tsxの {children}
の中に page.tsx
のコンテンツが出力されます。
つまり、出力内容としては下記になります。
return (
<html lang="en">
<body>
<h1>Hello, Next.js!</h1>
</body>
</html>
)
ページ毎に切り替わる部分なのか、ベースとなる共通となる部分なのかを見極めて記述を振り分けて実装していくイメージになるかと思います。

Next.jsプロジェクトの構造(原文:Next.js Project Structure)
このページで紹介されている App Router で使うフォルダ名やファイル名を挙げていきます。
トップレベルフォルダ
フォルダ名 | 説明 |
---|---|
app | App Routerを使用する際に使います。 |
無視してOK。App Routerのときは使わない。 | |
public | 静的ファイルを置く場所です。 |
src | (オプション)一般的に規模が大きくなるときに使われる。Pages Routerのときは使われる傾向にあったが、App Routerのときはどうなんだろう?この辺りは宗教上のお話になってくるかも? |
App Routerのルーティング規則
【ルーティングファイル】
とりあえず、layout.tsx と page.tsx と loading.tsx と not-found.tsx と error.tsx と route.ts だけ知っていればおおよそ実装はできるかと思います。
ファイル名 | 説明 |
---|---|
layout.tsx | レイアウト |
page.tsx | ページコンテンツ |
loading.tsx | ローディング中のUI |
not-found.tsx | Not Found(コンテンツが存在しないときに使われる。コンテンツが存在しないURLにアクセスされた場合にも使われる。) |
error.tsx | エラー(予期せぬエラーが発生したときに使われる。404とは違う。) |
global-error.tsx | エラー(ルートディレクトリを差し置いてこのエラーを優先して使う。強力なエラーのレンダリング。) |
route.ts | APIエンドポイント |
template.tsx | レイアウトの再レンダリング(現時点では理解してない) |
default.tsx | 不明 |

error 周りの実装をする際に知っておきたいこと。
React Developer Tools を利用することで人為的にエラーUIをレンダリングできるとのこと。
詳しい使い方は上記ページがわかりやすいかと思います。
App Routerはデフォルトでサーバーサイドレンダリングされる
App Router では、常にサーバーサイドでレンダリングされる。
クライアントサイトでレンダリングをしたい場合は "use client"
を記述すること。
サーバーサイドでレンダリングされるということは、クライアント側へのバンドルサイズを減らされることになる。データ量を大幅に削減できるので高速化が期待できる。クライアント側で処理しないといけないもの以外は基本的にサーバー側で処理させる方向で考えると良いだろう。
RSC(React Server Components)はSSR(Server Side Rendering)は同じではない。
どちらも一緒のように感じるが同じものでは無いらしい。( 参考リンク )
◆SSR(Server Side Rendering)
SSRはサーバー側で生のHTMLに書き起こしてくれる。
◆RSC(React Server Components)
RSCはサーバー側で必要なデータをまとめ、クライアント側に転送してくれる。レンダリング(DOMへの書き起こし)自体はクライアント側で行われる。
どちらもハイドレーション(DOMに対してイベントを適用していく処理)自体はクライアント側で実行される。
感覚がつかみにくいので、とりあえず上記の理解にしておく。

下層ページを作るには?
下層ページを作る例として、 /dashboard/
このURLにアクセスする想定でやっていきます。
ページを追加するには:
app/dashboard/page.tsx
を作ればOK。
export default function Page() {
return <h1>Hello, Dashboard Page!</h1>;
}
dashboardの下層ページ共通のレイアウトを作るには:
app/dashboard/layout.tsx
を作ればOK
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<>
<p>dashboardのlayoutの文言</p>
<section>{children}</section>
</>
);
}
/dashboard
にブラウザでアクセスすると、
app/layout.tsx
と app/dashboard/layout.tsx
と app/dashboard/page.tsx
を結合した形で出力されていることが分かるかと思います。
これが "ネストされた" という状態です。

ファイルやフォルダ構成には「正しい」や「間違い」はない。
どこにcomponentsフォルダを設置するのか、libフォルダを設置するのかなど...。これはプロジェクトやチーム毎に一貫性のあるルールさえ守っていれば正しいや間違いという概念はありません。
公式ドキュメント様が言っているのでその通りでしょう。
他の人と違うからおかしい、とかはなく、プロジェクトとチームにとって効果的な構造であれば問題ありません。

非同期の処理が入ったコンポーネントは Suspense を使おう。
Pages Router時代では、状態管理(useStateやjotai、Recoilなど)で「データ取得中」や「完了」のステータスを変更して出力を切り替えをしていた。
RSC(React Server Component)はサーバー側で処理される訳だが、サーバー側では状態管理ができない。
そこで出てくるのが React 標準機能の Suspense。
データ取得状況を把握し、いい感じに表示を切り替えてくれる。
const MyFetching = async () => {
await new Promise(async (resolve, reject) => {
try {
const url = "htt://jsonplaceholder.typicode.com/posts";
const res = await fetch(url, { cache: "no-store" });
resolve("");
} catch (e) {
reject("");
}
});
};
export const WaitTest = async () => {
const response = await MyFetching();
return (
<>
<p>テストコンポーネント</p>
</>
);
};
resolveは成功。
rejectは失敗。
tryの中でエラーへと切り替えたいときは、 new throw Error("")
でも catchから reject に飛ばすこともできる。テストする際は活用してみよう。
import { Suspense } from "react";
import { WaitTest } from "@/components/test";
import { ErrorBoundary } from "react-error-boundary";
export default function Page() {
return (
<div>
<ErrorBoundary fallback={<p>失敗しました。</p>}>
<Suspense fallback={<div>Loading...</div>}>
<WaitTest />
</Suspense>
</ErrorBoundary>
</div>
);
}
test.tsx
の MyFetching()
に非同期の処理が書かれている。
page.tsx
のJSXで <WaitTest />
として呼び出している。呼び出すと、 MyFetching()
が発火するので非同期処理も動く。それを <Suspense fallback={<div>Loading...</div>}> ~ </Suspense>
で状況を監視している。読み込み中のときは fallback
内の表示が行われる。
処理が正常に完了すると、
<WaitTest />
のビューである <p>テストコンポーネント</p>
が出力される。
処理失敗すると、
<ErrorBoundary fallback={<p>失敗しました。</p>}> ~ </ErrorBoundary>
の fallback
内の表示が行われる。
なお、本来は ErrorBoundary
は自分で実装する必要があるが、ここでは react-error-boundaryを使っている。インストール数も膨大でほとんどのプロジェクトで使われていることが推測できる。
また、エラーが起きたときに任意の処理ができる useErrorBoundary
というフックも用意されている。処理失敗でモーダルを表示するなど、色々な使い方ができるだろう。
【ちょっと愚痴】Suspenseを扱う記事のほとんどが ErrorBoundary に全く触れてないの不思議すぎ...。

Suspenseの説明が複雑になったので簡略化してみる。
上記のコード をベースに簡略化する。
なお、 resolveやrejectは到達した時点で処理が終了する。(挙動はreturnに似てる)
一言で表すとPromise文をそのままビューにしてしまう変換器みたいなもの!
下記で分からない場合は new Promise 周りの書き方の知識が不足していると思うので、Promiseの書き方を覚えましょう!
◆処理が完了していないとき(待機中)
<Suspense> の fallback
が表示されて終了。
◆ resolve("");
まで到達した場合
<WaitTest />
に書かれた <p>テストコンポーネント</p>
が表示されて完了。
◆ reject("");
に到達した場合
<ErrorBoundary> の fallback
が表示されて終了。

Fetch(データの取得)
App Routerでは、Fetchはサーバーコンポーネントの中で直接fetchするのが推奨されているとのこと。
というのも、リクエストをキャッシュし、複数のコンポーネントでリクエストした場合、重複してリクエストしないように内部で調整してくれているとのこと。
上記の図は公式ドキュメントより引用。
図を見ると A / B / C のリクエストがコンポーネントの中で大量にリクエストしているが、 memoize
しているおかげで A / B / C の3回のみリクエストされることが分かる。余計な通信を発生させない設計になっているので安心して良さそうだ。
なお、キャッシュをしたくないときもあるだろうから、下記は必ず読んでおこう。

useTransition
下記の記事が分かりやすかったです。
useTransition()
は、React 18 から追加された新機能。
フロントエンドの処理に時間がかかるとき、「切り替え中です」のようにローカルPCが頑張っていることをユーザーにお知らせすることができる。
例えば、フロント側で絞り込みフィルターを実装することを考えると、1,000,000件あるデータを絞り込むには相当時間がかかる。ユーザーとしては選択肢は雑にカチカチ操作したい。しかし、処理中だと操作をさせてくれないだろう。処理を待たなくては操作ができないのは最悪なUXである。
こういったときに useTransition
を使うと良い。
裏側では絞り込み処理をしていても、ユーザーの操作を受け付けてくれる。
また、処理中であることをユーザーにお知らせすることもできる。
今まではボタンを押すと1分間操作できなかったものが、特に処理の重さに関係なく1秒後には普通に押せるというわけだ。
重い処理と軽い処理を分けて表示を切り替える。
const [isPending, startTransition] = useTransition();
isPending
では処理中であることをユーザーにお知らせできるフラグ。
startTransition(() => {})
遅らせたい処理を書く。
入力を優先させて、レンダリングに関わる処理とレンダリングを後で行うということが簡単に実現できます。

useRouter
は next/navigation
になった
useRouter を使い場合、 import { useRouter } from "next/navigation";
とする必要がある。
また、 router.events が使えなくなった。