spring1018 / react-ui

0 stars 0 forks source link

Next.jsの考え方 #119

Open spring1018 opened 1 month ago

spring1018 commented 1 month ago

https://zenn.dev/akfm/books/nextjs-basic-principle

第1部 データフェッチ:

SCによって従来よりセキュアでシンプルな実装が可能になった一方、使いこなすには全く異なる設計思想が求められる

第2部 コンポーネント設計

第3部 キャッシュ

第4部 レンダリング

spring1018 commented 1 month ago

データフェッチ on SC

要約

データフェッチは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>;
}

バンドルサイズの軽減

トレードオフ

ユーザー操作とデータフェッチ

GraphQLとの相性の悪さ

spring1018 commented 1 month ago

データフェッチコロケーション

要約

データフェッチはデータを参照するコンポーネントにコロケーションし、コンポーネントの独立性を高めましょう。

背景

実装例

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) { return (

); }

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に対する理解と最適な設計が重要


コロケーション: コードをできるだけ関連性のある場所に配置することを指します。

spring1018 commented 1 month ago

Request Memorization

要約

データフェッチ層を分離して、Request Memoizationを生かせる設計を心がける

背景

設計・プラクティス

オプションの指定ミスによりRequest Memoizationが効かないことなどがないよう、複数のコンポーネントで利用しうるデータフェッチ処理はデータフェッチ層として分離する

// プロダクト情報取得のデータフェッチ層
export async function getProduct(id: string) {
  const res = await fetch(`https://dummyjson.com/products/${id}`, {
    // 独自ヘッダーなど
  });
  return res.json();
}

ファイル構成

server-only package

データフェッチ層を誤ってクライアントサイドで利用することを防ぐため、 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();
}

トレードオフ

特になし

spring1018 commented 1 month ago

並行データフェッチ

要約

以下のパターンを駆使して、データフェッチが可能な限り並行になるよう設計しましょう。

背景

設計・プラクティス

データフェッチ単位のコンポーネント分割

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[];
  // ...
}

並行fetch()

データフェッチ順には依存関係がなくとも参照の単位が不可分な場合には、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()),
  ]);

  // ...
}

preloadパターン

また読む

トレードオフ

N+1データフェッチ

データフェッチ単位を小さくコンポーネントに分割していくとN+1データフェッチが発生する可能性がある

spring1018 commented 1 month ago

N+1とDataLoader

要約

コンポーネント単位の独立性を高めるとN+1データフェッチが発生しやすくなるので、DataLoaderのバッチ処理を利用して解消する

背景

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;
};

設計・プラクティス

DataLoader

またよむ

Next.jsにおけるDataLoaderの利用

またよむ

トレードオフ

Eager Loadingパターン

spring1018 commented 1 month ago

細粒度のREST API

要約

バックエンドのREST API設計は、Next.js側の設計にも大きく影響をもたらします。App Router(React Server Components)におけるバックエンドAPIは、細粒度な単位に分割されていることが望まく、可能な限りこれを意識した設計を行う

背景

リソース単位の粒度とトレードオフ

粒度のバランスと利用者都合

設計・プラクティス

トレードオフ

バックエンドとの通信回数

バックエンドAPI開発チームの理解

spring1018 commented 1 month ago

ユーザー操作とデータフェッチ

要約

ユーザー操作に基づくデータフェッチと再レンダリングには、Server Actionsと useActionState() を利用する

背景

設計・プラクティス

useActionState()

トレードオフ

URLシェア・リロード対応

データ操作に伴う再レンダリング

spring1018 commented 1 month ago

Client Componentsのユースケース

要約

Client Componentsを使うべき代表的なユースケースを覚えておく。

背景

設計・プラクティス

CCを利用すべき場面は以下の3つ

クライアントサイド処理

クライアントサイド処理を必要とする場合。

サードパーティコンポーネント

"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>
  );
}

RSC Payload転送量の削減

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>
  );
}

トレードオフ

Client Boundaryと暗黙的なClient Components

spring1018 commented 1 month ago

Compositionパターン

要約

Compositionパターンを駆使して、Server Componentsを中心に組み立てたコンポーネントツリーからClient Componentsを適切に切り分ける

背景

制約1: Client Componentsはサーバーモジュールをimportできない

"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>;
}

制約2: Client Boundary

"use client";が記述されたモジュールからimportされるモジュール以降は、全て暗黙的にクライアントモジュールとして扱われる

設計・プラクティス

前述の通り、App RouterでServer Componentsの設計を活かすにはClient Componentsを独立した形に切り分けることが重要。これには大きく以下2つの方法がある。

コンポーネントツリーの末端をClient Componentsにする

header.tsx

import { SearchBar } from "./search-bar"; // Client Components

// page.tsxなどのServer Componentsから利用される
export function Header() {
  return (
    <header>
      <h1>My App</h1>
      <SearchBar />
    </header>
  );
}

Compositionパターンを活用する

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>
  );
}

トレードオフ

「後からComposition」の手戻り

spring1018 commented 1 month ago

Container/Presentational パターン

要約

データ取得はContainer Components・データの参照はPresentational Componentsに分離し、テスト容易性を向上させる

背景

ReactコンポーネントのテストといえばReact Testing Library(以下RTL)やStorybookなどを利用することが主流だが、現時点でこれらのServer Components対応の状況は芳しくない。

React Testing Library

Storybook

設計・プラクティス

従来のContainer/Presentationalパターン

React Server ComponentsにおけるContainer/Presentationalパターン

実装例

トレードオフ

エコシステム側が将来対応する可能性

spring1018 commented 1 month ago

Container 1stな設計とディレクトリ構成

要約

背景

React Server Componentsでは特に、Compositionパターンを後から適用しようとすると大幅なClient Componentsの設計見直しや書き換えが発生しがち。こういった手戻りを防ぐためにも、設計の手順はとても重要。

設計・プラクティス

実装例

よくあるブログ記事の画面実装を例に、Container 1stな設計を実際にやってみる。 画面に必要な情報はPost、User、Commentsの3つを仮定し、それぞれに対してContainer Componentsを考える。

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 // 汎用的な関数など
│  └── ...
└── ...

トレードオフ

広すぎるexport

前述のように、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
│  └── ...
└── ...

上記の構成ではの外から参照されるモジュールはindex.tsxのみの想定です。ただ実際には、presentational.tsxで定義したコンポーネントもプロジェクトのどこからでも参照することができます。

このような同一ディレクトリにおいてのみ利用することを想定したモジュール分割においては、eslint-plugin-import-accessを利用すると予期せぬ外部からのimportを制限することができます。

上記のようなディレクトリ設計に沿わない場合でも、Presentational ComponentsはContainer Componentsのみが利用しうる実質的なプライベート定義として扱うようにしましょう。