Closed12

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

masa5714masa5714

Next.jsの公式ドキュメントを元にやっていく。

【検索からこのスクラップに来た人への注意】
このスクラップでは自分用の断片的なメモです。
Pages Router を一度は触ったことがある前提で進めていきます。
基本的な内容は無視することがありますし、正確性は保証しません。自分なりの解釈の内容も含まれることがあります。解釈違いがあればコメントでご指摘お待ちしております。


最初に覚えておいた方が楽そうなこと

  • 【AppRouterはデフォルトでSSRされる】use client を使わない限りサーバー側で処理される。
masa5714masa5714

App Router(新しい方) vs Pages Router(従来のやつ)

これまで、 Pages Routerで制作した古いアプリケーションも引き続きサポートされるとのこと。
急いで移行する理由は特に無さそう。

とはいえ、Vercel(Next.js作っている組織)的には 新規プロジェクトでの制作は App Router を推奨している ことは認識しておきたい。

ドキュメントの読み方

Google検索等でNext.jsの公式ドキュメントに辿り着くこともあるでしょう。
その際は必ずパンくずリストを確認するようにしましょう。

先頭部分で「App Router向け」なのか、「Pages Router」向けのドキュメントなのかが書かれている。ここを見落とすと辛いことになるかもなので注意しましょう!

masa5714masa5714

ディレクトリの説明

npx create-next-app@latest を実行して生成されたフォルダに含まれるディレクトリの説明をします。

app ディレクトリ

既に下記の2ファイルが作られているはずです。

  • layout.tsx
  • page.tsx

これらのファイルはトップページ / にアクセスした際に処理され、レンダリングされます。

layout.tsx

app/layout.tsx には、ルートレイアウトを作成します。ルートレイアウトとは、レンダリングの際に必ず出力されるベースとなるHTMLのこと。

例えば、 app/layout.tsx の下記の記述。

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 を見ていきます。

app/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>
  )

ページ毎に切り替わる部分なのか、ベースとなる共通となる部分なのかを見極めて記述を振り分けて実装していくイメージになるかと思います。

masa5714masa5714

Next.jsプロジェクトの構造(原文:Next.js Project Structure)

https://nextjs.org/docs/getting-started/project-structure

このページで紹介されている App Router で使うフォルダ名やファイル名を挙げていきます。

トップレベルフォルダ

フォルダ名 説明
app App Routerを使用する際に使います。
page 無視して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 不明
masa5714masa5714

error 周りの実装をする際に知っておきたいこと。

React Developer Tools を利用することで人為的にエラーUIをレンダリングできるとのこと。

https://dev.to/ninariccimarie/how-to-trigger-react-error-boundary-with-react-developer-tools-1ccg

詳しい使い方は上記ページがわかりやすいかと思います。

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に対してイベントを適用していく処理)自体はクライアント側で実行される。

感覚がつかみにくいので、とりあえず上記の理解にしておく。

masa5714masa5714

下層ページを作るには?

https://nextjs.org/docs/getting-started/project-structure#dynamic-routes

下層ページを作る例として、 /dashboard/ このURLにアクセスする想定でやっていきます。

ページを追加するには:
app/dashboard/page.tsx を作ればOK。

app/dashboard/page.tsx
export default function Page() {
  return <h1>Hello, Dashboard Page!</h1>;
}

dashboardの下層ページ共通のレイアウトを作るには:
app/dashboard/layout.tsx を作ればOK

app/dashboard/layout.tsx
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <p>dashboardのlayoutの文言</p>
      <section>{children}</section>
    </>
  );
}

/dashboard にブラウザでアクセスすると、
app/layout.tsxapp/dashboard/layout.tsxapp/dashboard/page.tsx を結合した形で出力されていることが分かるかと思います。

これが "ネストされた" という状態です。

masa5714masa5714

ファイルやフォルダ構成には「正しい」や「間違い」はない。

どこにcomponentsフォルダを設置するのか、libフォルダを設置するのかなど...。これはプロジェクトやチーム毎に一貫性のあるルールさえ守っていれば正しいや間違いという概念はありません。

https://nextjs.org/docs/app/building-your-application/routing/colocation#project-organization-strategies

公式ドキュメント様が言っているのでその通りでしょう。
他の人と違うからおかしい、とかはなく、プロジェクトとチームにとって効果的な構造であれば問題ありません。

masa5714masa5714

非同期の処理が入ったコンポーネントは Suspense を使おう。

Pages Router時代では、状態管理(useStateやjotai、Recoilなど)で「データ取得中」や「完了」のステータスを変更して出力を切り替えをしていた。

RSC(React Server Component)はサーバー側で処理される訳だが、サーバー側では状態管理ができない。

そこで出てくるのが React 標準機能の Suspense。
データ取得状況を把握し、いい感じに表示を切り替えてくれる。

components/test.tsx
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 に飛ばすこともできる。テストする際は活用してみよう。

page.tsx
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.tsxMyFetching() に非同期の処理が書かれている。

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 というフックも用意されている。処理失敗でモーダルを表示するなど、色々な使い方ができるだろう。

https://www.npmjs.com/package/react-error-boundary

【ちょっと愚痴】Suspenseを扱う記事のほとんどが ErrorBoundary に全く触れてないの不思議すぎ...。

masa5714masa5714

Suspenseの説明が複雑になったので簡略化してみる。

上記のコード をベースに簡略化する。
なお、 resolveやrejectは到達した時点で処理が終了する。(挙動はreturnに似てる)

一言で表すとPromise文をそのままビューにしてしまう変換器みたいなもの!

下記で分からない場合は new Promise 周りの書き方の知識が不足していると思うので、Promiseの書き方を覚えましょう!

◆処理が完了していないとき(待機中)
<Suspense> の fallback が表示されて終了。

resolve(""); まで到達した場合
<WaitTest /> に書かれた <p>テストコンポーネント</p> が表示されて完了。

reject(""); に到達した場合
<ErrorBoundary> の fallback が表示されて終了。

masa5714masa5714

Fetch(データの取得)

App Routerでは、Fetchはサーバーコンポーネントの中で直接fetchするのが推奨されているとのこと。
というのも、リクエストをキャッシュし、複数のコンポーネントでリクエストした場合、重複してリクエストしないように内部で調整してくれているとのこと。

https://nextjs.org/docs/app/building-your-application/caching#request-memoization

上記の図は公式ドキュメントより引用。

図を見ると A / B / C のリクエストがコンポーネントの中で大量にリクエストしているが、 memoize しているおかげで A / B / C の3回のみリクエストされることが分かる。余計な通信を発生させない設計になっているので安心して良さそうだ。

なお、キャッシュをしたくないときもあるだろうから、下記は必ず読んでおこう。

https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#opting-out-of-data-caching

masa5714masa5714

useTransition

下記の記事が分かりやすかったです。

https://github.com/reactwg/react-18/discussions/65

https://zenn.dev/engineer_titan/articles/19dc7a8359cf69

https://note.com/kai815/n/n6980b41cfbfb

useTransition() は、React 18 から追加された新機能。
フロントエンドの処理に時間がかかるとき、「切り替え中です」のようにローカルPCが頑張っていることをユーザーにお知らせすることができる。

例えば、フロント側で絞り込みフィルターを実装することを考えると、1,000,000件あるデータを絞り込むには相当時間がかかる。ユーザーとしては選択肢は雑にカチカチ操作したい。しかし、処理中だと操作をさせてくれないだろう。処理を待たなくては操作ができないのは最悪なUXである。

こういったときに useTransition を使うと良い。
裏側では絞り込み処理をしていても、ユーザーの操作を受け付けてくれる。
また、処理中であることをユーザーにお知らせすることもできる。

今まではボタンを押すと1分間操作できなかったものが、特に処理の重さに関係なく1秒後には普通に押せるというわけだ。

重い処理と軽い処理を分けて表示を切り替える。

const [isPending, startTransition] = useTransition();

isPending では処理中であることをユーザーにお知らせできるフラグ。
startTransition(() => {}) 遅らせたい処理を書く。

入力を優先させて、レンダリングに関わる処理とレンダリングを後で行うということが簡単に実現できます。

masa5714masa5714

useRouternext/navigation になった

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

このスクラップは2024/03/10にクローズされました