mcpField
R
react-server-components

react-server-components

github.com/patricio0312rev開発63277 回インストール42 週間前に更新
リポジトリ

react-server-components について

react-server-componentsは、Next.js App RouterでReact Server Componentsアプリを構築する開発スキルです。サーバーとクライアントの境界、データ取得、ストリーミング、サーバー側の更新を扱います。

主な機能

  1. 01サーバー・クライアントコンポーネント境界の設計
  2. 02サーバーコンポーネントでのデータ取得
  3. 03Suspenseによるセクションのストリーミング
  4. 04サーバーアクションによる更新処理
  5. 05キャッシュとエラーバウンダリーの設定

活用シーン

  1. 01サーバーコンポーネントを使ったデータページの構築
  2. 02クライアントコンポーネントへの対話機能の追加
  3. 03ダッシュボード各部の段階的な読み込み
  4. 04サーバーアクションによるフォーム送信の処理

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/patricio0312rev/skills --skill react-server-components

インストール後、Claude Code で/react-server-componentsを入力して読み込まれたか確認します。

詳細

React Server Components

React Server Components と Next.js App Router で、高速なアプリケーションを構築します。

コアワークフロー

  1. RSC モデルを理解する:サーバーコンポーネントとクライアントコンポーネント
  2. コンポーネントツリーを設計する:サーバーとクライアントの境界を計画する
  3. データ取得を実装する:サーバーコンポーネントでデータを取得する
  4. インタラクティブ性を追加する:必要な箇所でクライアントコンポーネントを使う
  5. ストリーミングを有効にする:段階的な読み込みに Suspense を使う
  6. 最適化する:クライアントバンドルのサイズを最小限に抑える

サーバーコンポーネントとクライアントコンポーネント

主な違い
機能サーバーコンポーネントクライアントコンポーネント
レンダリングサーバーのみサーバー+クライアント
バンドルサイズJS は送信されないバンドルに追加される
データ取得DB/API に直接アクセスuseEffect/TanStack Query
状態/エフェクトフックなしReact フックをすべて利用可能
イベントハンドラーなしあり
ブラウザー APIなしあり
ファイルディレクティブデフォルト(なし)'use client'
それぞれを使う場面

サーバーコンポーネント(デフォルト)

  • 静的コンテンツ
  • データ取得
  • バックエンドリソースへのアクセス
  • 大きな依存関係(Markdown、構文ハイライト)
  • 機密性の高いロジック/トークン

クライアントコンポーネント

  • インタラクティブな UI(onClick、onChange)
  • useState、useEffect、useReducer
  • ブラウザー API(localStorage、geolocation)
  • 状態を持つカスタムフック
  • サードパーティ製クライアントライブラリ

基本パターン

サーバーコンポーネント(デフォルト)
// app/users/page.tsx (Server Component - no directive needed)
import { db } from '@/lib/db';

async function getUsers() {
  return db.user.findMany({
    orderBy: { createdAt: 'desc' },
  });
}

export default async function UsersPage() {
  const users = await getUsers();

  return (
    <div>
      <h1>Users</h1>
      <ul>
        {users.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}
クライアントコンポーネント
// components/Counter.tsx
'use client';

import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  );
}
コンポジションパターン
// app/dashboard/page.tsx (Server Component)
import { db } from '@/lib/db';
import { DashboardClient } from './DashboardClient';
import { StatsCard } from '@/components/StatsCard';

async function getStats() {
  const [users, orders, revenue] = await Promise.all([
    db.user.count(),
    db.order.count(),
    db.order.aggregate({ _sum: { total: true } }),
  ]);
  return { users, orders, revenue: revenue._sum.total };
}

export default async function DashboardPage() {
  const stats = await getStats();

  return (
    <div>
      {/* Server-rendered static content */}
      <h1>Dashboard</h1>

      {/* Server components with data */}
      <div className="grid grid-cols-3 gap-4">
        <StatsCard title="Users" value={stats.users} />
        <StatsCard title="Orders" value={stats.orders} />
        <StatsCard title="Revenue" value={`$${stats.revenue}`} />
      </div>

      {/* Client component for interactivity */}
      <DashboardClient initialData={stats} />
    </div>
  );
}
// app/dashboard/DashboardClient.tsx
'use client';

import { useState } from 'react';
import { DateRangePicker } from '@/components/DateRangePicker';
import { Chart } from '@/components/Chart';

interface DashboardClientProps {
  initialData: Stats;
}

export function DashboardClient({ initialData }: DashboardClientProps) {
  const [dateRange, setDateRange] = useState({ from: null, to: null });

  return (
    <div>
      <DateRangePicker value={dateRange} onChange={setDateRange} />
      <Chart data={initialData} />
    </div>
  );
}

データ取得パターン

並列データ取得
// app/page.tsx
async function getUser(id: string) {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}

async function getPosts(userId: string) {
  const res = await fetch(`/api/users/${userId}/posts`);
  return res.json();
}

async function getComments(userId: string) {
  const res = await fetch(`/api/users/${userId}/comments`);
  return res.json();
}

export default async function ProfilePage({ params }: { params: { id: string } }) {
  // Parallel fetching - all requests start simultaneously
  const [user, posts, comments] = await Promise.all([
    getUser(params.id),
    getPosts(params.id),
    getComments(params.id),
  ]);

  return (
    <div>
      <UserHeader user={user} />
      <PostsList posts={posts} />
      <CommentsList comments={comments} />
    </div>
  );
}
順次データ取得(依存関係がある場合)
export default async function OrderPage({ params }: { params: { id: string } }) {
  // Sequential - second fetch depends on first
  const order = await getOrder(params.id);
  const customer = await getCustomer(order.customerId);

  return (
    <div>
      <OrderDetails order={order} />
      <CustomerInfo customer={customer} />
    </div>
  );
}
キャッシュと再検証
// Static data (cached indefinitely)
async function getStaticData() {
  const res = await fetch('https://api.example.com/static', {
    cache: 'force-cache', // Default
  });
  return res.json();
}

// Dynamic data (no cache)
async function getDynamicData() {
  const res = await fetch('https://api.example.com/dynamic', {
    cache: 'no-store',
  });
  return res.json();
}

// Revalidate after time
async function getRevalidatedData() {
  const res = await fetch('https://api.example.com/data', {
    next: { revalidate: 3600 }, // Revalidate every hour
  });
  return res.json();
}

// Revalidate by tag
async function getTaggedData() {
  const res = await fetch('https://api.example.com/products', {
    next: { tags: ['products'] },
  });
  return res.json();
}

// Trigger revalidation
import { revalidateTag } from 'next/cache';
revalidateTag('products');

Suspense によるストリーミング

ページレベルのストリーミング
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { StatsCards, StatsCardsSkeleton } from './StatsCards';
import { RecentOrders, RecentOrdersSkeleton } from './RecentOrders';
import { TopProducts, TopProductsSkeleton } from './TopProducts';

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>

      {/* Each section streams independently */}
      <Suspense fallback={<StatsCardsSkeleton />}>
        <StatsCards />
      </Suspense>

      <div className="grid grid-cols-2 gap-4 mt-8">
        <Suspense fallback={<RecentOrdersSkeleton />}>
          <RecentOrders />
        </Suspense>

        <Suspense fallback={<TopProductsSkeleton />}>
          <TopProducts />
        </Suspense>
      </div>
    </div>
  );
}
データを含むコンポーネント
// app/dashboard/StatsCards.tsx
import { db } from '@/lib/db';

async function getStats() {
  // Simulate slow query
  await new Promise((resolve) => setTimeout(resolve, 2000));
  return db.stats.findFirst();
}

export async function StatsCards() {
  const stats = await getStats();

  return (
    <div className="grid grid-cols-4 gap-4">
      <Card title="Revenue" value={stats.revenue} />
      <Card title="Orders" value={stats.orders} />
      <Card title="Customers" value={stats.customers} />
      <Card title="Products" value={stats.products} />
    </div>
  );
}

export function StatsCardsSkeleton() {
  return (
    <div className="grid grid-cols-4 gap-4">
      {[...Array(4)].map((_, i) => (
        <div key={i} className="h-24 bg-gray-200 animate-pulse rounded" />
      ))}
    </div>
  );
}
読み込み状態
// app/products/loading.tsx
export default function ProductsLoading() {
  return (
    <div className="grid grid-cols-3 gap-4">
      {[...Array(9)].map((_, i) => (
        <div key={i} className="h-64 bg-gray-200 animate-pulse rounded" />
      ))}
    </div>
  );
}

サーバーアクション

フォームアクション
// app/contact/page.tsx
import { submitContact } from './actions';

export default function ContactPage() {
  return (
    <form action={submitContact}>
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button type="submit">Send</button>
    </form>
  );
}
// app/contact/actions.ts
'use server';

import { z } from 'zod';
import { revalidatePath } from 'next/cache';

const schema = z.object({
  email: z.string().email(),
  message: z.string().min(10),
});

export async function submitContact(formData: FormData) {
  const validatedFields = schema.safeParse({
    email: formData.get('email'),
    message: formData.get('message'),
  });

  if (!validatedFields.success) {
    return { error: validatedFields.error.flatten().fieldErrors };
  }

  await db.contact.create({
    data: validatedFields.data,
  });

  revalidatePath('/contacts');
  return { success: true };
}
useActionState を使う
// components/ContactForm.tsx
'use client';

import { useActionState } from 'react';
import { submitContact } from '@/app/contact/actions';

export function ContactForm() {
  const [state, formAction, isPending] = useActionState(submitContact, null);

  return (
    <form action={formAction}>
      <input name="email" type="email" required />
      {state?.error?.email && <p className="text-red-500">{state.error.email}</p>}

      <textarea name="message" required />
      {state?.error?.message && <p className="text-red-500">{state.error.message}</p>}

      <button type="submit" disabled={isPending}>
        {isPending ? 'Sending...' : 'Send'}
      </button>

      {state?.success && <p className="text-green-500">Message sent!</p>}
    </form>
  );
}

楽観的更新

// components/LikeButton.tsx
'use client';

import { useOptimistic, useTransition } from 'react';
import { likePost } from '@/app/actions';

interface LikeButtonProps {
  postId: string;
  initialLikes: number;
  isLiked: boolean;
}

export function LikeButton({ postId, initialLikes, isLiked }: LikeButtonProps) {
  const [isPending, startTransition] = useTransition();
  const [optimisticState, addOptimistic] = useOptimistic(
    { likes: initialLikes, isLiked },
    (state, newIsLiked: boolean) => ({
      likes: newIsLiked ? state.likes + 1 : state.likes - 1,
      isLiked: newIsLiked,
    })
  );

  const handleClick = () => {
    startTransition(async () => {
      addOptimistic(!optimisticState.isLiked);
      await likePost(postId);
    });
  };

  return (
    <button onClick={handleClick} disabled={isPending}>
      {optimisticState.isLiked ? '❤️' : '🤍'} {optimisticState.likes}
    </button>
  );
}

一般的なユースケースのパターン

認証チェック
// app/dashboard/layout.tsx
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/auth';

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await getSession();

  if (!session) {
    redirect('/login');
  }

  return (
    <div>
      <nav>Welcome, {session.user.name}</nav>
      {children}
    </div>
  );
}
サーバーデータをクライアントに渡す
// app/products/[id]/page.tsx
import { getProduct } from '@/lib/products';
import { ProductGallery } from './ProductGallery';

export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await getProduct(params.id);

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>

      {/* Pass serializable data to client component */}
      <ProductGallery
        images={product.images}
        initialIndex={0}
      />
    </div>
  );
}
ネストされたクライアントコンポーネント
// components/Modal.tsx
'use client';

import { createContext, useContext, useState } from 'react';

const ModalContext = createContext<{
  open: boolean;
  setOpen: (open: boolean) => void;
} | null>(null);

export function ModalProvider({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  return (
    <ModalContext.Provider value={{ open, setOpen }}>
      {children}
    </ModalContext.Provider>
  );
}

export function useModal() {
  const context = useContext(ModalContext);
  if (!context) throw new Error('useModal must be used within ModalProvider');
  return context;
}

ベストプラクティス

  1. サーバーコンポーネントから始める:デフォルトではサーバーコンポーネントを使い、必要な場合にのみ 'use client' を追加します
  2. クライアントとの境界を下位に置く:インタラクティブ性は末端のコンポーネントに保ちます
  3. データを並列に取得する:独立したデータには Promise.all を使います
  4. ストリーミングに Suspense を使う:UX を向上させるために段階的に読み込みます
  5. データ取得をコロケーションする:データを使う場所で取得します
  6. クライアントバンドルを最小限にする:サイズの大きなライブラリはサーバー側に置きます
  7. 適切にキャッシュする:再検証の戦略を使います
  8. エラーに対処する:error.tsx 境界を使います

出力チェックリスト

すべての RSC 実装には、以下を含める必要があります。

  • データ取得用のサーバーコンポーネント
  • 必要な箇所のみの 'use client'
  • ストリーミング用の Suspense 境界
  • 各セクションの読み込みスケルトン
  • Promise.all による並列データ取得
  • 更新処理用のサーバーアクション
  • 適切なキャッシュ戦略
  • エラー境界(error.tsx)
  • レイアウトでの認証チェック
  • 最小限のクライアント JavaScript バンドル

コメント