Open spring1018 opened 1 month ago
データフェッチはCCではなく、SCで行う。
export async function ProductTitle({ id }) {
const res = await fetch(`https://dummyjson.com/products/${id}`);
const product = await res.json();
return <div>{product.title}</div>;
}
データフェッチはデータを参照するコンポーネントにコロケーションし、コンポーネントの独立性を高めましょう。
getServerSideProps
や getStaticProps
などページの外側で非同期関数を宣言し、Next.jsがこれを実行した結果をpropsとしてページコンポーネントに渡すという設計がなされていた
type ProductProps = {
product: Product;
};
export const getServerSideProps = (async () => {
const res = await fetch("https://dummyjson.com/products/1");
const product = await res.json();
return { props: { product } };
}) satisfies GetServerSideProps
export default function ProductPage({
product,
}: InferGetServerSidePropsType
); }
function ProductContents({ product }: ProductProps) { return ( <>
<ProductDetail product={product} />
<ProductFooter product={product} />
</>
); }
// ...
## 設計・プラクティス
- App RouterではSCでのデータフェッチが利用可能なので、できるだけ末端のコンポーネントへデータフェッチをコロケーションすることを推奨している
- 小規模な実装であればページコンポーネントでデータフェッチをしても問題はない
- ページコンポーネントが肥大化していくと中間層でのバケツリレーが発生しやすくなるので、できるだけ末端のコンポーネントでデータフェッチを行うことを推奨する
- 「それでは全く同じデータフェッチが何度も実行されてしまうのではないか」と懸念するかもしれないが、App RouterではRequest Memoizationによってデータフェッチがメモ化されるため、全く同じデータフェッチが複数回実行されることないように設計されている
### 実装例
- データフェッチが各コンポーネントにコロケーションされたことで、バケツリレーがなくなる
- 子コンポーネントはそれぞれ必要な情報を自身で取得しているため、ページ全体でどんなデータフェッチを行っているかを気にする必要がなくなる
```tsx
type ProductProps = {
product: Product;
};
// <ProductLayout>は`layout.tsx`へ移動
export default function ProductPage() {
return (
<>
<ProductHeader />
<ProductDetail />
<ProductFooter />
</>
);
}
async function ProductHeader() {
const res = await fetchProduct();
return <>...</>;
}
async function ProductDetail() {
const res = await fetchProduct();
return <>...</>;
}
// ...
async function fetchProduct() {
// Request Memoizationにより、実際のデータフェッチは1回しか実行されない
const res = await fetch("https://dummyjson.com/products/1");
return res.json();
}
データフェッチのコロケーションを実現する要はRequest Memoizationなので、Request Memoizationに対する理解と最適な設計が重要
コロケーション: コードをできるだけ関連性のある場所に配置することを指します。
データフェッチ層を分離して、Request Memoizationを生かせる設計を心がける
オプションの指定ミスによりRequest Memoizationが効かないことなどがないよう、複数のコンポーネントで利用しうるデータフェッチ処理はデータフェッチ層として分離する
// プロダクト情報取得のデータフェッチ層
export async function getProduct(id: string) {
const res = await fetch(`https://dummyjson.com/products/${id}`, {
// 独自ヘッダーなど
});
return res.json();
}
app/products/fetcher.ts
app/products/_lib/fetcher.ts
app/products/_lib/fetcher/product.ts
データフェッチ層を誤ってクライアントサイドで利用することを防ぐため、 server-only
パッケージを利用すると良い
// Client Compomnentsでimportするとerror
import "server-only";
export async function getProduct(id: string) {
const res = await fetch(`https://dummyjson.com/products/${id}`, {
// 独自ヘッダーなど
});
return res.json();
}
特になし
以下のパターンを駆使して、データフェッチが可能な限り並行になるよう設計しましょう。
fetch()
<PostBody />
と <Comments />
(およびその子孫)は並行レンダリングされるので、データフェッチも並行となるfunction Page({ params: { id } }: { params: { id: string } }) {
return (
<>
<PostBody postId={id} />
<CommentsWrapper>
<Comments postId={id} />
</CommentsWrapper>
</>
);
}
async function PostBody({ postId }: { postId: string }) {
const res = await fetch(`https://dummyjson.com/posts/${postId}`);
const post = (await res.json()) as Post;
// ...
}
async function Comments({ postId }: { postId: string }) {
const res = await fetch(`https://dummyjson.com/posts/${postId}/comments`);
const comments = (await res.json()) as Comment[];
// ...
}
データフェッチ順には依存関係がなくとも参照の単位が不可分な場合には、Promise.all()(もしくはPromise.allSettled())とfetch()を組み合わせることで、複数のデータフェッチを並行に実行できる
async function Page() {
const [user, posts] = await Promise.all([
fetch(`https://dummyjson.com/users/${id}`).then((res) => res.json()),
fetch(`https://dummyjson.com/posts/users/${id}`).then((res) => res.json()),
]);
// ...
}
また読む
データフェッチ単位を小さくコンポーネントに分割していくとN+1データフェッチが発生する可能性がある
コンポーネント単位の独立性を高めるとN+1データフェッチが発生しやすくなるので、DataLoaderのバッチ処理を利用して解消する
getPosts()
を1回、 getUser()
をN回呼び出すことになる
https://dummyjson.com/posts
https://dummyjson.com/users/1
https://dummyjson.com/users/2
https://dummyjson.com/users/3
page.tsx
import { type Post, getPosts, getUser } from "./fetcher";
export const dynamic = "force-dynamic";
export default async function Page() {
const { posts } = await getPosts();
return (
<>
<h1>Posts</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>
<PostItem post={post} />
</li>
))}
</ul>
</>
);
}
async function PostItem({ post }: { post: Post }) {
const user = await getUser(post.userId);
return (
<>
<h3>{post.title}</h3>
<dl>
<dt>author</dt>
<dd>{user?.username ?? "[unknown author]"}</dd>
</dl>
<p>{post.body}</p>
</>
);
}
fetcher.ts
export async function getPosts() {
const res = await fetch("https://dummyjson.com/posts");
return (await res.json()) as {
posts: Post[];
};
}
type Post = {
id: number;
title: string;
body: string;
userId: number;
};
export async function getUser(id: number) {
const res = await fetch(`https://dummyjson.com/users/${id}`);
return (await res.json()) as User;
}
type User = {
id: number;
username: string;
};
https://dummyjson.com/users/?id=1&id=2&id=3...
のように、idを複数指定してUser情報を一括で取得できるよう設計するパターンがよく知られているまたよむ
またよむ
バックエンドのREST API設計は、Next.js側の設計にも大きく影響をもたらします。App Router(React Server Components)におけるバックエンドAPIは、細粒度な単位に分割されていることが望まく、可能な限りこれを意識した設計を行う
ユーザー操作に基づくデータフェッチと再レンダリングには、Server Actionsと useActionState()
を利用する
useActionState()
Client Componentsを使うべき代表的なユースケースを覚えておく。
CCを利用すべき場面は以下の3つ
クライアントサイド処理を必要とする場合。
onClick()
や onChange()
といったイベントハンドラの利用useState()
やuseReducer()
など)やライフサイクルhooks(useEffect()
など)の利用"use client";
import { Accordion } from "third-party-library";
export default Accordion;
"use client";
import { Accordion } from "third-party-library";
export function SideBar() {
return (
<div>
<Accordion>{/* ... */}</Accordion>
</div>
);
}
Client Componentsを含むJavaScriptバンドルは1回しかロードされませんが、Server ComponentsはレンダリングされるたびにRSC Payloadが転送される。そのため、繰り返しレンダリングされるコンポーネントはRSC Payloadの転送量を削減する目的でClient Componentsにすることが望ましい場合がある。
export async function Product() {
const product = await fetchProduct();
return (
<div class="... /* 大量のtailwindクラス */">
<div class="... /* 大量のtailwindクラス */">
<div class="... /* 大量のtailwindクラス */">
<div class="... /* 大量のtailwindクラス */">
{/* `product`参照 */}
</div>
</div>
</div>
</div>
);
}
export async function Product() {
const product = await fetchProduct();
return <ProductPresentaional product={product} />;
}
"use client";
export function ProductPresentaional({ product }: { product: Product }) {
return (
<div class="... /* 大量のtailwindクラス */">
<div class="... /* 大量のtailwindクラス */">
<div class="... /* 大量のtailwindクラス */">
<div class="... /* 大量のtailwindクラス */">
{/* `product`参照 */}
</div>
</div>
</div>
</div>
);
}
Compositionパターンを駆使して、Server Componentsを中心に組み立てたコンポーネントツリーからClient Componentsを適切に切り分ける
"use client";
import { useState } from "react";
import { UserInfo } from "./user-info"; // Server Components
export function SideMenu() {
const [open, setOpen] = useState(false);
return (
<>
<UserInfo />
<div>
<button type="button" onClick={() => setOpen((prev) => !prev)}>
toggle
</button>
<div>...</div>
</div>
</>
);
}
この制約に対し唯一例外となるのが"use server";が付与されたファイルや関数、つまり Server Actions
actions.ts
"use server";
export async function create() {
// サーバーサイド処理
}
create-button.tsx
"use client";
import { create } from "./actions"; // 💡Server Actionsならimportできる
export function CreateButton({ children }: { children: React.ReactNode }) {
return <button onClick={create}>{children}</button>;
}
"use client";が記述されたモジュールからimportされるモジュール以降は、全て暗黙的にクライアントモジュールとして扱われる
前述の通り、App RouterでServer Componentsの設計を活かすにはClient Componentsを独立した形に切り分けることが重要。これには大きく以下2つの方法がある。
header.tsx
import { SearchBar } from "./search-bar"; // Client Components
// page.tsxなどのServer Componentsから利用される
export function Header() {
return (
<header>
<h1>My App</h1>
<SearchBar />
</header>
);
}
side-menu.tsx
"use client";
import { useState } from "react";
// `children`に`<UserInfo>`などのServer Componentsを渡すことが可能!
export function SideMenu({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<>
{children}
<div>
<button type="button" onClick={() => setOpen((prev) => !prev)}>
toggle
</button>
<div>...</div>
</div>
</>
);
}
page.tsx
import { UserInfo } from "./user-info"; // Server Components
import { SideMenu } from "./side-menu"; // Client Components
/**
* Client Components(`<SideMenu>`)の子要素として
* Server Components(`<UserInfo>`)を渡せる
*/
export function Page() {
return (
<div>
<SideMenu>
<UserInfo />
</SideMenu>
<main>{/* ... */}</main>
</div>
);
}
データ取得はContainer Components・データの参照はPresentational Componentsに分離し、テスト容易性を向上させる
ReactコンポーネントのテストといえばReact Testing Library(以下RTL)やStorybookなどを利用することが主流だが、現時点でこれらのServer Components対応の状況は芳しくない。
React Server Componentsでは特に、Compositionパターンを後から適用しようとすると大幅なClient Componentsの設計見直しや書き換えが発生しがち。こういった手戻りを防ぐためにも、設計の手順はとても重要。
よくあるブログ記事の画面実装を例に、Container 1stな設計を実際にやってみる。 画面に必要な情報はPost、User、Commentsの3つを仮定し、それぞれに対してContainer Componentsを考える。
<PostContainer postId={postId}>
<UserProfileContainer id={post.userId}>
<CommentsContainer postId={postId}>
postId
はURLから取得できますがuserIdはPost情報に含まれているので、<UserProfileContainer>
は<PostContainer>
で呼び出される形になる。一方<CommentsContainer>
は postId
を元にレンダリングされるので、<PostContainer>
と並行に呼び出すことが可能です。
これらを加味して、まずはContainer Componentsのツリー構造をpage.tsxに実際に書き出す。各ContainerやPresentational Componentsの実装は後から行うので、ここでは仮実装で構造を設計することに集中する。
export default async function Page({
params: { postId },
}: {
params: { postId: string };
}) {
return (
<>
<PostContainer postId={postId} />
<CommentsContainer postId={postId} />
</>
);
}
async function PostContainer({ postId }: { postId: string }) {
const post = await getPost(postId);
return (
<PostPresentation post={post}>
<UserProfileContainer id={post.userId} />
</PostPresentation>
);
}
// ...
以降は省略。
重要なのは、ディレクトリをContainer単位で、ファイルをContainer/Presentationalで分割すること。
app
├── <Segment>
│ ├── page.tsx
│ ├── layout.tsx
│ ├── _containers
│ │ ├── <Container Name>
│ │ │ ├── index.tsx
│ │ │ ├── container.tsx
│ │ │ ├── presentational.tsx
│ │ │ └── ...
│ │ └── ...
│ ├── _components // 汎用的なClient Components
│ ├── _lib // 汎用的な関数など
│ └── ...
└── ...
前述のように、Presentational ComponentsはContainer Componentsの実装詳細と捉えることもできるので、本来Presentational Componentsはプライベート定義として扱うことが好ましい。 ディレクトリ構成例に基づいた設計の場合、Presentational Componentsはpresentational.tsxで定義されます。
_containers
├── <Container Name> // e.g. `post-list`, `user-profile`
│ ├── index.tsx // Container Componentsをexport
│ ├── container.tsx
│ ├── presentational.tsx
│ └── ...
└── ...
上記の構成では
このような同一ディレクトリにおいてのみ利用することを想定したモジュール分割においては、eslint-plugin-import-accessを利用すると予期せぬ外部からのimportを制限することができます。
上記のようなディレクトリ設計に沿わない場合でも、Presentational ComponentsはContainer Componentsのみが利用しうる実質的なプライベート定義として扱うようにしましょう。
https://zenn.dev/akfm/books/nextjs-basic-principle
第1部 データフェッチ:
SCによって従来よりセキュアでシンプルな実装が可能になった一方、使いこなすには全く異なる設計思想が求められる
第2部 コンポーネント設計
第3部 キャッシュ
第4部 レンダリング