1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.js Server Componentsの並列データフェッチ設計まとめ

Posted at

Next.js Server Componentsの並列データフェッチ設計まとめ

この記事は、Next.js(App Router) で **「できる限り並列にデータフェッチする」**ための設計パターンを、自分用メモとして整理したものです。

特に大事だと感じたのは、この一文:

並列になる・ならないを決めているのは「コンポーネント構造」そのものではなく、「どこで await しているか」

これを頭に置きつつ、以下のパターンを使い分けていくイメージです。

  • データフェッチ単位のコンポーネント分割
  • Promise.all による並行 fetch
  • preload パターン(Request Memoization前提)
  • N+1 と DataLoader(+API設計)

1. 並列になる・ならないを決めるのは「どこで await するか」

基本ルール

  • 兄弟の async Server Component 同士は並行レンダリングされる(=中の fetch も並列になりやすい)
  • ❌ 親コンポーネントが await してから子を描画する構造にすると、その部分はウォーターフォールになりやすい

並列になる例(兄弟コンポーネント)

// app/posts/[id]/page.tsx
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;

  return (
    <>
      <PostBody postId={id} />
      <Comments postId={id} />
    </>
  );
}

async function PostBody({ postId }: { postId: string }) {
  const res = await fetch(`https://dummyjson.com/posts/${postId}`);
  const post = await res.json();
  // ...
}

async function Comments({ postId }: { postId: string }) {
  const res = await fetch(`https://dummyjson.com/posts/${postId}/comments`);
  const comments = await res.json();
  // ...
}

<PostBody /><Comments /> は「兄弟」なので 並行レンダリング される

それぞれの fetch もほぼ同時に飛ぶ
→ 並列フェッチになる

途中に Fragment や薄いラッパーを挟んでも、兄弟関係が変わらなければ挙動は同じです。

return (
  <>
    <>
      <PostBody postId={id} />
    </>
    <>
      <Comments postId={id} />
    </>
  </>
);

これは見た目はネストしているけど、「兄弟の子孫」としては同じ扱い。

大事なのは「構造」ではなく await の位置。

ウォーターフォールになる例(親で await してから子を描画)

async function Page() {
  const user = await fetchUser(); // ← ここで一回待つ

  return (
    <>
      <UserHeader user={user} />
      <UserPosts userId={user.id} /> {/* user がないと呼べない */}
    </>
  );
}

UserPosts は user がないと呼べない設計になっている

つまり、fetchUser → 完了 → UserPosts レンダリング開始 → その中で fetch …
という流れ

**直列(ウォーターフォール)**の構造になりやすい

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

考え方

「データ間に依存関係がない」「参照するコンポーネントも別」なら、コンポーネントごとにデータフェッチを分割する

兄弟(または兄弟の子孫)として配置すれば、データフェッチが並列になる

export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;

  return (
    <>
      <PostBody postId={id} />
      <Comments postId={id} />
    </>
  );
}

async function PostBody({ postId }: { postId: string }) {
  const res = await fetch(`https://dummyjson.com/posts/${postId}`);
  const post = await res.json();
  // ...
}

async function Comments({ postId }: { postId: string }) {
  const res = await fetch(`https://dummyjson.com/posts/${postId}/comments`);
  const comments = await res.json();
  // ...
}

メリット

コンポーネントごとに **「自分が必要なデータだけ」**をフェッチできる(コロケーション)

自然に並列フェッチされる

注意点

細かく分けすぎると N+1 が起きやすい

→ 後半の DataLoader セクションで対処する

  1. パターン2:Promise.all での並行 fetch
    考え方

「フェッチ順には依存関係がない」「でも参照単位としてはセットで扱いたい」というとき
1つのコンポーネント内で Promise.all を使って並行実行する

async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;

  const [user, posts] = await Promise.all([
    fetch(`https://dummyjson.com/users/${id}`).then((res) => res.json()),
    fetch(`https://dummyjson.com/posts/user/${id}`).then((res) => res.json()),
  ]);

  return (
    <>
      <UserSummary user={user} />
      <UserPosts posts={posts} />
    </>
  );
}

user と posts の取得順に依存は無い

Promise.all によって、それぞれの fetch が並行で叩かれる

パターン3:preload パターン(Request Memoization)

問題設定

コンポーネント構造上、どうしても親 → 子と深くネストせざるを得ない

その子孫コンポーネントで同じデータ(例:currentUser)を使いたい

何も考えないと、親がレンダリングされた後に子がフェッチして…とウォーターフォールになりそう

preload パターンのアイデア

親コンポーネントで「先に」fetch を仕掛けておく

中身は await せず、「投げっぱなし」

実際に値が必要になった場所(子孫)で await getCurrentUser() を呼ぶと、

すでにリクエストが飛んでいるので結果が早く返ってくる

これは Next.js の Request Memoization が効いている前提のテクニック

コード例

// app/fetcher.ts
import "server-only";

export const preloadCurrentUser = () => {
  // preloadなので `await` しない
  void getCurrentUser();
};

export async function getCurrentUser() {
  const res = await fetch("https://dummyjson.com/user/me");
  return (await res.json()) as User;
}

// app/products/[id]/page.tsx
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;

  // Product や Comments のさらに子孫で user を利用するため、
  // 親コンポーネントで preload する
  preloadCurrentUser();

  return (
    <>
      <Product productId={id} />
      <Comments productId={id} />
    </>
  );
}

ページレベルで preloadCurrentUser() を呼んでおく

実際にどこかで await getCurrentUser() したときには、

すでにリクエストが飛んでいるので、「見かけ上」早く返ってくる

注意点

preloadCurrentUser() が残っているのに、どこからも getCurrentUser() が呼ばれなくなった場合:

無駄なリクエストになる

利用されなくなったら preload を消し忘れないこと

N+1 と DataLoader(+API設計)

N+1 の例

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

getPosts() は1回
getUser() は投稿数 N 回

合計で N+1 リクエスト になる

API設計での解決パターン

バックエンド側で、

GET /users/?id=1&id=2&id=3...

のように 複数IDを一括で取得できるエンドポイント を用意しておく。

その上でフロント側で DataLoader を使って、

短時間に呼ばれた getUser(1), getUser(2), getUser(3) を
内部的に GET /users/?id=1&id=2&id=3 に バッチングする

DataLoader パターンの構文と挙動

コード全体

import DataLoader from "dataloader";
import * as React from "react";

// リクエストごとに DataLoader インスタンスを保持
const getUserLoader = React.cache(
  () => new DataLoader((keys: readonly number[]) => batchGetUser(keys)),
);

export async function getUser(id: number) {
  const userLoader = getUserLoader();
  return userLoader.load(id);
}

async function batchGetUser(keys: readonly number[]) {
  // keys を元にデータフェッチ (例: id=1&id=2&id=3...)
  const res = await fetch(
    `https://dummyjson.com/users/?${keys.map((key) => `id=${key}`).join("&")}`,
  );
  const { users } = (await res.json()) as { users: User[] };
  return users;
}

各パートの意味

batchGetUser(keys)
→ keys(ユーザーIDの配列)をまとめて API に投げる関数

new DataLoader(batchFn)
→ load(id) の呼び出しを集約して、batchFn(keys) を一発だけ呼ぶためのラッパー

React.cache(() => new DataLoader(...))
→ 「1つの HTTP リクエストの中では、同じ DataLoader インスタンスを再利用する」ためのキャッシュ
→ 他ユーザーのリクエストとは共有されない想定なので安全

getUser(id)
→ 呼び出し側から見ると「普通の getUser(id)」
中で userLoader.load(id) に委譲されている

つまり、配列をもらうようにしておいて必要なものたちだけを取得しに行くように設計すること

初めて聞いたとき「1人だけ取るときに無駄じゃない?」と疑問になったが、
挙動を整理すると:

DataLoader は 「短時間に複数の load(id) が呼ばれたら、それをまとめる」 ライブラリ

もしそのリクエスト中で getUser(42) が 1回だけなら、

keys:[42] だけになる

バッチAPIにしても /users/?id=42 のような1件分のリクエストで済む

つまり:
「いらないユーザーまで勝手に取る」ような動きではない

実際に getUser された ID しか batchGetUser に渡されない

一方で、こういうケースでは恩恵が大きい:
同じリクエスト中に getUser(1), getUser(2), getUser(3) が呼ばれる
= N+1 パターン

DataLoader が「短時間に溜まった load(id) 呼び出し」をバッチング

batchGetUser([1, 2, 3]) のように1回のAPIで処理できる

まとめ:自分の学び

最後に、今回の学びを自分向けに整理すると:

並列になる・ならないを決めているのは「構造」ではなく「どこで await しているか」

兄弟 Server Components / Promise.all / preload をうまく使う
コンポーネント単位でデータフェッチをコロケーションする
そのコンポーネントが必要とするデータは、基本そのコンポーネント(か近いところ)で取る

Layout や Page は「ラッパー」「ルーティング」「スコープの定義」に集中させる
API設計とセットで考えて N+1 を回避する
バッチ取得用エンドポイントを用意する
フロント側は DataLoader + React.cache で N+1 をバッチングする

このあたりを意識して設計していくと、
「遅くない」「無駄のない」「読みやすい」データフェッチ構造
機能ごとにコロケーションされた、見通しの良いコンポーネント構成

を両立できそう

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?