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>
);
}
現在の制限事項と今後
現在の制限
- Viteサポート待ち: 現在は Parcel を使用しているため、安定版リリースは Viteの RSC サポートを待っている
- バンドラー統合: より簡単なバンドラー統合を目指して開発中
- リバリデーション戦略: 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 を活用した次世代のフルスタックフレームワークとして、さらに強力になることでしょう。