4
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?

この記事を読んでくださり、ありがとうございます。

FEを実装するようになり、React、Next.js、TypeScriptを扱うようになりました。

FEなのにクライアントとサーバーの2種類があるとのことで、BEとは明らかに異なる概念への理解が難しいと感じています。

と、いうわけで同じような場面に出くわす人が多いであろう、RCCとRSCの違いをまとめてみます。

React Server Component (RSC) と React Client Component (RCC) の違い

RSCとは

  • サーバーでレンダリングされるコンポーネント
  • データ取得やHTMLの組み立てを担当し、クライアントに効率的に送信する
  • 副作用やブラウザ依存の処理は持たない
  • StreamingやSuspenseと組み合わせることでUXを改善できる
    • skelton uiはSuspenseを使えばサッと実装可能

RCCとは

  • ブラウザで動作するコンポーネント
  • 状態管理やイベントハンドリング、副作用を伴う処理など、クライアントでしかできない処理を担当する

設計ガイドライン

基本方針

  • できるだけRSCに寄せることで、クライアント側のJavaScriptバンドルサイズを削減し、パフォーマンスを向上させる
  • RCCはブラウザでしかできない処理に限定する

RSCの責務

  • サーバーでデータ取得(DBクエリ、API呼び出し)
  • HTMLの組み立てと初期状態の生成
  • Streaming + Suspenseで部分的にレンダリングを先行
  • 副作用やブラウザ依存の処理は持たない

RCCの責務

  • 状態管理(useState
  • 副作用(useEffect
  • ユーザー操作に応じたUI更新
  • DOM操作やイベントハンドリング

例外的な設計パターン

基本的にはRSCで担当しますが、要件によってはRCCで処理する方が適切な場合があります。
今回はそのうち1つを経験したこともあり、ご紹介します。

大容量データの扱い

ファイルのバイナリデータや動画などの大容量データをサーバー側で直接扱うと、メモリ圧迫やネットワーク負荷が発生します。
この場合、RSCはキー(ハッシュ値やID)やメタ情報のみを管理し、RCC側で必要なタイミングで大容量データを取得・処理する設計が望ましいです。

ユースケース例

  • 画像編集アプリでは、RSCは画像IDやメタ情報を返し、RCCで画像バイナリをロードして表示する
  • 動画プレイヤーでは、RSCは動画のURLやハッシュを返し、RCCでストリーミング再生する
  • ファイルアップロードでは、RSCはアップロード済みファイルのキーを管理し、RCCでプレビューやダウンロードする

コード例で見る分離の実装

次の例は Next.js の App Router を前提にしています。
RSCがデータ取得と静的UIを担当し、RCCがインタラクションと状態管理を担当します。

サーバーでデータを取得して表示するページ

app/page.tsx はデフォルトでサーバーコンポーネントです。

// app/page.tsx (RSC)
import { Suspense } from 'react';
import { getArticles } from '@/lib/data';
import ClientFilter from '@/components/ClientFilter';

export default async function Page() {
  const articles = await getArticles(); // DBやAPIから取得(RSCでOK)

  return (
    <main>
      <h1>記事一覧</h1>

      {/* Streaming と Suspense を活用(RSC側でOK) */}
      <Suspense fallback={<p>読み込み中...</p>}>
        <ArticleList articles={articles} />
      </Suspense>

      {/* フィルタのインタラクションは RCC に委譲 */}
      <ClientFilter initialArticles={articles} />
    </main>
  );
}

function ArticleList({ articles }: { articles: { id: string; title: string }[] }) {
  return (
    <ul>
      {articles.map((a) => (
        <li key={a.id}>{a.title}</li>
      ))}
    </ul>
  );
}

クライアントで状態とイベントを扱うフィルタ

components/ClientFilter.tsx はクライアントコンポーネントです。

// components/ClientFilter.tsx (RCC)
'use client';

import { useMemo, useState } from 'react';

type Article = { id: string; title: string };

export default function ClientFilter({ initialArticles }: { initialArticles: Article[] }) {
  const [query, setQuery] = useState('');

  const filtered = useMemo(() => {
    const q = query.trim().toLowerCase();
    return q ? initialArticles.filter(a => a.title.toLowerCase().includes(q)) : initialArticles;
  }, [query, initialArticles]);

  return (
    <section>
      <label>
        検索
        <input
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="タイトルで検索"
        />
      </label>
      <ul>
        {filtered.map(a => <li key={a.id}>{a.title}</li>)}
      </ul>
    </section>
  );
}

サーバーアクションで作成を行い、結果をRCCで扱う

RSCはアクションでDBを書き込み、RCCはフォームのUXを扱います。

// app/actions.ts (RSCのサーバーアクション)
'use server';  // 明示的に使ってます

import { revalidatePath } from 'next/cache';
import { createArticle } from '@/lib/data';

export async function createArticleAction(formData: FormData) {
  const title = String(formData.get('title') ?? '');
  if (!title) {
    throw new Error('タイトルは必須です');
  }
  const id = await createArticle({ title });
  revalidatePath('/'); // ページを再検証
  return { id, title };
}
// components/ClientCreateForm.tsx (RCC)
'use client';

import { useState } from 'react';
import { createArticleAction } from '@/app/actions';

export default function ClientCreateForm() {
  const [pending, setPending] = useState(false);
  const [message, setMessage] = useState<string | null>(null);

  async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setPending(true);
    setMessage(null);

    const formData = new FormData(e.currentTarget);
    try {
      const res = await createArticleAction(formData);
      setMessage(`作成しました ID=${res.id}`);
      e.currentTarget.reset();
    } catch (err) {
      setMessage('作成に失敗しました');
    } finally {
      setPending(false);
    }
  }

  return (
    <form onSubmit={onSubmit}>
      <label>
        タイトル
        <input name="title" placeholder="記事タイトル" />
      </label>
      <button type="submit" disabled={pending}>
        {pending ? '送信中' : '作成'}
      </button>
      {message && <p>{message}</p>}
    </form>
  );
}

大容量データはキーで管理し、RCCで取得する例

RSCはハッシュやIDを返すだけにして、RCCでバイナリを取得します。

// app/files/[id]/page.tsx (RSC)
import { getFileMetaById } from '@/lib/files';
import FileViewer from '@/components/FileViewer';

export default async function FilePage({ params }: { params: { id: string } }) {
  const meta = await getFileMetaById(params.id);
  // meta には { id, name, hash, mimeType } などを含める
  return (
    <main>
      <h1>ファイル表示</h1>
      {/* バイナリは読み込まず、キーだけ渡す */}
      <FileViewer fileId={meta.id} fileHash={meta.hash} mimeType={meta.mimeType} />
    </main>
  );
}
// components/FileViewer.tsx (RCC)
'use client';

import { useEffect, useState } from 'react';

type Props = {
  fileId: string;
  fileHash: string; // 署名や検証用に使える
  mimeType: string;
};

export default function FileViewer({ fileId, fileHash, mimeType }: Props) {
  const [url, setUrl] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let revoked = false;

    async function load() {
      setError(null);
      try {
        // クライアントでのみ大容量を取得(CDNや署名付きURLを想定)
        const res = await fetch(`/api/files/${fileId}`); // 署名検証や認可はAPI側で
        if (!res.ok) throw new Error('取得に失敗しました');
        const blob = await res.blob();
        const objectUrl = URL.createObjectURL(blob);
        if (!revoked) setUrl(objectUrl);
      } catch (e) {
        setError('読み込みに失敗しました');
      }
    }

    load();
    return () => {
      revoked = true;
      if (url) URL.revokeObjectURL(url);
    };
  }, [fileId]);

  if (error) return <p>{error}</p>;
  if (!url) return <p>読み込み中...</p>;

  if (mimeType.startsWith('image/')) {
    return <img src={url} alt={fileHash} style={{ maxWidth: '100%' }} />;
  }
  if (mimeType === 'application/pdf') {
    return <iframe src={url} title="PDF" style={{ width: '100%', height: 600 }} />;
  }
  return <a href={url} download>ダウンロード</a>;
}

APIルートでバイナリを返す例

サーバー側ではストリーミングや範囲リクエスト対応を検討します。

// app/api/files/[id]/route.ts (EdgeでなくNode runtimeを想定)
import { NextRequest } from 'next/server';
import { getFileStreamById } from '@/lib/files';

export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
  const { stream, mimeType, size } = await getFileStreamById(params.id);
  return new Response(stream, {
    headers: {
      'Content-Type': mimeType,
      'Content-Length': String(size),
      // 必要に応じてキャッシュや範囲リクエスト対応ヘッダを設定
    },
  });
}

メリット

  • パフォーマンス向上により初期表示が速くなる
  • サイトの表示に際して、すでにデータのある状態でクローラーが徘徊することになるため、SEOに強くなる
  • クライアント側のバンドルが小さくなり、体感速度が上がる
  • 責務分離によりコードの見通しが良くなる
  • 大容量データの取り扱いでも、サーバーのメモリ消費を抑えられる

まとめ

RSCはデータ取得と静的UIを、RCCはインタラクションと状態管理を担うのが基本です。
ただし大容量データのようなケースでは、RSCはキーやメタ情報だけを扱い、RCCが実データを取得する方針が有効です。
この分離を意識することで、読みやすく保守しやすいフロントエンドを作れます。

4
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
4
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?