RemixでRSCが使える!React Server Componentsの実装パターン3選

RemixがついにReact Server Componentsをサポート!RSCの段階的導入方法、パフォーマンス最適化、実装パターンを徹底解説します。

RemixでRSCが使える!React Server Componentsの実装パターン3選

2024 年末、Remix チームは React Server Components (RSC) のプレビューサポートを発表しました。これは、Remix の強力なデータローディング機能と RSC の利点を組み合わせた革新的なアプローチです。

本記事では、Remix における RSC の実装方法、段階的な導入戦略、そして実践的な使用例を詳しく解説します。

React Server Componentsとは

React Server Components (RSC) は、サーバー上でのみ実行される Reactコンポーネントです。クライアントへの JavaScriptバンドルサイズを削減し、データフェッチングを簡素化できます。

// Server Component (サーバーでのみ実行)
async function ProductDetails({ id }) {
  // 直接データベースにアクセス可能
  const product = await db.product.findUnique({ where: { id } });

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

RemixでのRSC有効化

インストールと設定

# React Router v7をインストール
npm install react-router@latest react-router-dom@latest

# RSCプレビューを有効化
npm install @react-router/dev@latest

vite.config.ts の設定:

import { reactRouter } from '@react-router/dev/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    reactRouter({
      // RSCプレビューを有効化
      future: {
        unstable_rsc: true,
      },
    }),
  ],
});

3つの実装パターン

パターン1: Loaderから動的コンポーネントを返す

既存の loader から直接 React要素を返すことができます。

// app/routes/product.$id.tsx
export async function loader({ params }: LoaderFunctionArgs) {
  const { contentBlocks, ...product } = await getProduct(params.productId);

  return {
    product,
    // React要素を直接返す
    content: (
      <div className="product-content">
        {contentBlocks.map((block) => {
          switch (block.type) {
            case "image":
              return <ImageBlock key={block.id} {...block} />;
            case "video":
              return <VideoBlock key={block.id} {...block} />;
            case "text":
              return <TextBlock key={block.id} {...block} />;
            case "reviews":
              return <ReviewsBlock key={block.id} productId={params.productId} />;
            default:
              return null;
          }
        })}
      </div>
    ),
  };
}

export default function Product() {
  const { product, content } = useLoaderData<typeof loader>();

  return (
    <div>
      <h1>{product.name}</h1>
      <div className="price">${product.price}</div>
      {/* Server Componentのコンテンツを表示 */}
      {content}
    </div>
  );
}

パターン2: Server Component Routes

ルート全体を Server Component として定義できます。

// app/routes/dashboard.tsx
// "use client"ディレクティブがないため、Server Component

import { getUser, getAnalytics, getRecentActivity } from "~/models";

export default async function Dashboard() {
  // 並列でデータフェッチング
  const [user, analytics, activity] = await Promise.all([
    getUser(),
    getAnalytics(),
    getRecentActivity()
  ]);

  return (
    <div className="dashboard">
      <h1>ダッシュボード</h1>

      <UserProfile user={user} />

      <div className="grid">
        <AnalyticsChart data={analytics} />
        <ActivityFeed items={activity} />
      </div>
    </div>
  );
}

// Server Component内の子コンポーネント
async function UserProfile({ user }) {
  // 追加のデータフェッチングも可能
  const preferences = await getUserPreferences(user.id);

  return (
    <div className="user-profile">
      <img src={user.avatar} alt={user.name} />
      <h2>{user.name}</h2>
      <PreferenceSettings preferences={preferences} />
    </div>
  );
}

パターン3: Client ComponentsとServer Functions

// app/components/TodoList.tsx
"use client"; // Client Component

import { useState } from "react";
import { addTodo, toggleTodo, deleteTodo } from "./todo-actions";

export function TodoList({ initialTodos }) {
  const [todos, setTodos] = useState(initialTodos);
  const [input, setInput] = useState("");

  async function handleAdd() {
    const newTodo = await addTodo(input);
    setTodos([...todos, newTodo]);
    setInput("");
  }

  async function handleToggle(id: string) {
    await toggleTodo(id);
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, done: !todo.done } : todo
    ));
  }

  return (
    <div>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
      />
      <button onClick={handleAdd}>追加</button>

      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.done}
              onChange={() => handleToggle(todo.id)}
            />
            {todo.text}
          </li>
        ))}
      </ul>
    </div>
  );
}
// app/components/todo-actions.ts
'use server'; // Server Functions

import { db } from '~/db';

export async function addTodo(text: string) {
  const todo = await db.todo.create({
    data: { text, done: false },
  });
  return todo;
}

export async function toggleTodo(id: string) {
  const todo = await db.todo.findUnique({ where: { id } });
  return db.todo.update({
    where: { id },
    data: { done: !todo.done },
  });
}

export async function deleteTodo(id: string) {
  await db.todo.delete({ where: { id } });
}

パフォーマンス最適化

N+1問題の解決

Remix の RSC 実装では、自動的にクエリをバッチ処理して最適化します。

// app/routes/blog.tsx
export default async function BlogList() {
  const posts = await getPosts();

  return (
    <div>
      {posts.map(post => (
        // 各PostCardが個別にauthorをフェッチしても、
        // Remixが自動的にバッチ処理
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

async function PostCard({ post }) {
  // この呼び出しは自動的にバッチ処理される
  const author = await getAuthor(post.authorId);

  return (
    <article>
      <h2>{post.title}</h2>
      <p>by {author.name}</p>
      <p>{post.excerpt}</p>
    </article>
  );
}

ミドルウェアによる最適化

// app/middleware/cache.ts
import { LRUCache } from 'lru-cache';

const cache = new LRUCache<string, any>({
  max: 500,
  ttl: 1000 * 60 * 5, // 5分
});

export async function withCache<T>(key: string, fn: () => Promise<T>): Promise<T> {
  const cached = cache.get(key);
  if (cached) return cached;

  const result = await fn();
  cache.set(key, result);
  return result;
}

// 使用例
export async function getProduct(id: string) {
  return withCache(`product:${id}`, async () => {
    return db.product.findUnique({ where: { id } });
  });
}

段階的な移行戦略

ステップ1: 特定のルートから開始

// app/routes/about.tsx
// まず静的なページから始める
export default async function About() {
  const { content } = await getPageContent("about");

  return (
    <div>
      <h1>About Us</h1>
      <div dangerouslySetInnerHTML={{ __html: content }} />
    </div>
  );
}

ステップ2: 動的コンテンツの追加

// app/routes/products.tsx
export async function loader() {
  const products = await getProducts();

  return {
    products,
    // 特定の部分だけRSCに移行
    featuredSection: <FeaturedProducts />
  };
}

async function FeaturedProducts() {
  const featured = await getFeaturedProducts();

  return (
    <section className="featured">
      <h2>注目の商品</h2>
      <div className="grid">
        {featured.map(product => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </section>
  );
}

ステップ3: インタラクティブ要素の統合

// app/routes/cart.tsx
import { CartItems } from "~/components/CartItems";

export default async function Cart() {
  const items = await getCartItems();
  const recommendations = await getRecommendations();

  return (
    <div>
      {/* Client Component for interactivity */}
      <CartItems initialItems={items} />

      {/* Server Component for static content */}
      <RecommendationsList items={recommendations} />
    </div>
  );
}

実践的なユースケース

1. CMSコンテンツの動的レンダリング

// app/routes/$slug.tsx
export async function loader({ params }: LoaderFunctionArgs) {
  const page = await cms.getPage(params.slug);

  return {
    title: page.title,
    content: (
      <ContentRenderer blocks={page.blocks} />
    )
  };
}

async function ContentRenderer({ blocks }) {
  return (
    <>
      {blocks.map(block => {
        switch (block.type) {
          case "hero":
            return <HeroSection {...block.data} />;
          case "features":
            return <FeatureGrid features={block.data.features} />;
          case "testimonials":
            return <TestimonialsCarousel items={block.data.items} />;
          case "cta":
            return <CallToAction {...block.data} />;
          default:
            return null;
        }
      })}
    </>
  );
}

2. リアルタイムデータとの統合

// app/routes/dashboard.live.tsx
"use client";

import { useEffect, useState } from "react";
import { LiveData } from "~/components/LiveData";

export default function LiveDashboard() {
  const [serverData, setServerData] = useState(null);

  useEffect(() => {
    // 初回データの取得
    fetchServerData().then(setServerData);

    // WebSocketでリアルタイム更新
    const ws = new WebSocket(process.env.WS_URL);
    ws.onmessage = (event) => {
      setServerData(JSON.parse(event.data));
    };

    return () => ws.close();
  }, []);

  return <LiveData data={serverData} />;
}

3. 認証とアクセス制御

// app/routes/admin.tsx
import { redirect } from "@remix-run/node";
import { AdminPanel } from "~/components/AdminPanel";

export default async function Admin() {
  const user = await getCurrentUser();

  if (!user || user.role !== "admin") {
    throw redirect("/login");
  }

  const [stats, users, logs] = await Promise.all([
    getAdminStats(),
    getUsers(),
    getActivityLogs()
  ]);

  return (
    <AdminPanel
      user={user}
      stats={stats}
      users={users}
      logs={logs}
    />
  );
}

エラーハンドリング

// app/routes/error-boundary.tsx
export function ErrorBoundary() {
  const error = useRouteError();

  if (isRouteErrorResponse(error)) {
    return (
      <div>
        <h1>{error.status} {error.statusText}</h1>
        <p>{error.data}</p>
      </div>
    );
  }

  return (
    <div>
      <h1>エラーが発生しました</h1>
      <p>{error?.message || "不明なエラー"}</p>
    </div>
  );
}

現在の制限事項と今後

現在の制限

  1. Viteサポート待ち: 現在は Parcel を使用しているため、安定版リリースは Viteの RSC サポートを待っている
  2. バンドラー統合: より簡単なバンドラー統合を目指して開発中
  3. リバリデーション戦略: Server Functions 実行後の再検証戦略を改善中

今後のロードマップ

// 将来的に可能になる機能の例
// app/future-features.tsx

// Streaming SSR with Suspense
export default async function StreamingPage() {
  return (
    <div>
      <h1>ページタイトル</h1>
      <Suspense fallback={<LoadingSpinner />}>
        <SlowDataComponent />
      </Suspense>
    </div>
  );
}

// Partial Prerendering
export const config = {
  prerender: "auto",
  revalidate: 3600 // 1時間
};

// Server Components with WebAssembly
async function HeavyComputation() {
  const wasm = await import("./computation.wasm");
  const result = await wasm.compute();
  return <div>{result}</div>;
}

ベストプラクティス

1. コンポーネントの分離戦略

// ✅ 良い例:明確な責任分離
// app/components/ProductPage.tsx
export default async function ProductPage({ id }) {
  const product = await getProduct(id);

  return (
    <div>
      <ProductInfo product={product} />
      <ProductActions productId={id} /> {/* Client Component */}
      <RelatedProducts categoryId={product.categoryId} />
    </div>
  );
}

// ❌ 悪い例:混在した責任
export default function BadProductPage({ id }) {
  const [product, setProduct] = useState(null);

  useEffect(() => {
    // Client Componentでデータフェッチング
    fetchProduct(id).then(setProduct);
  }, [id]);

  return <div>{/* ... */}</div>;
}

2. データフェッチングの最適化

// 並列フェッチング
export default async function OptimizedPage() {
  const [a, b, c] = await Promise.all([
    fetchA(),
    fetchB(),
    fetchC()
  ]);

  return <div>{/* ... */}</div>;
}

// ウォーターフォールを避ける
export default async function Page() {
  const user = await getUser();
  // userに依存しないデータは並列で取得
  const [posts, comments] = await Promise.all([
    getPosts(user.id),
    getComments(user.id)
  ]);

  return <div>{/* ... */}</div>;
}

まとめ

Remix の RSC 実装は、既存の Remix アプリケーションに段階的に RSC を導入できる柔軟なアプローチを提供します。

主なメリット:

  • 段階的導入: 既存のコードを大幅に変更することなく、部分的に RSC を採用可能
  • パフォーマンス最適化: 自動的なクエリバッチングとキャッシング
  • 開発体験の向上: Server Functions と Client Components のシームレスな統合
  • 柔軟性: 3 つの異なる実装パターンから選択可能

Viteの RSC サポートが完了すれば、Remix は RSC を活用した次世代のフルスタックフレームワークとして、さらに強力になることでしょう。

BP

BitPluse Team

Building the future of software development, one line at a time.

Keep Learning

Explore more articles and expand your knowledge

View All Articles