Next.jsの考え方 を読む
とある現場の実装が複雑なバケツリレーでしんどかった思い出
それもあってServer Componentsによる末端でのデータ取得に可能性を感じています
以下、
「React Cacheを使用して関数呼び出しの結果をキャッシュ」
「ブラウザの組み込みRequest Memoizationを利用」
の比較(普段supabase を使っており、fetchを用いずcacheを使用してデータの再利用をしているため)
// Approach 1: React Cache with Supabase
// lib/products.js
import { cache } from 'react'
import { supabase } from './supabase'
export const getProduct = cache(async (id) => {
const { data, error } = await supabase
.from('products')
.select('*')
.eq('id', id)
.single()
if (error) throw new Error('Failed to fetch product')
return data
})
// app/products/[id]/page.js
import { getProduct } from '@/lib/products'
export default async function ProductPage({ params }) {
const product = await getProduct(params.id)
return <div>{product.name}</div>
}
// Approach 2: Request Memoization with fetch
type ProductProps = {
product: Product;
};
export default function ProductPage() {
return (
<>
<ProductHeader />
<ProductDetail />
<ProductFooter />
</>
);
}
async function ProductHeader() {
const res = await fetchProduct();
return <>...</>;
}
async function ProductDetail() {
const res = await fetchProduct();
return <>...</>;
}
async function fetchProduct() {
const res = await fetch("https://dummyjson.com/products/1");
return res.json();
}
これら2つのアプローチを比較してみましょう:
-
データフェッチの方法:
- アプローチ1: Supabaseクライアントを使用してデータベースから直接フェッチ
- アプローチ2:
fetch
APIを使用して外部APIからデータをフェッチ
-
キャッシング方法:
- アプローチ1: React Cacheを使用して関数呼び出しの結果をキャッシュ
- アプローチ2: ブラウザの組み込みRequest Memoizationを利用
-
コンポーネント構造:
- アプローチ1: 単一の非同期コンポーネントでデータをフェッチし表示
- アプローチ2: 複数の非同期コンポーネントに分割し、各コンポーネントでデータをフェッチ
-
エラーハンドリング:
- アプローチ1: エラーを明示的にスロー
- アプローチ2: エラーハンドリングが明示的に示されていない
-
柔軟性:
- アプローチ1: キャッシュされた関数を再利用可能
- アプローチ2: 各コンポーネントが独立してデータをフェッチ
-
パフォーマンス:
- アプローチ1: React Cacheにより、同一の引数による関数呼び出しが最適化される
- アプローチ2: Request Memoizationにより、同一リクエストが最適化される
-
コード再利用性:
- アプローチ1:
getProduct
関数を他の場所でも再利用可能 - アプローチ2:
fetchProduct
関数を他のコンポーネントでも使用可能
- アプローチ1:
-
データの一貫性:
- アプローチ1: 単一のデータフェッチで全てのコンポーネントにデータを提供
- アプローチ2: 各コンポーネントが独立してデータをフェッチするが、Request Memoizationにより実際のフェッチは1回のみ
評価:
- アプローチ1(React Cache with Supabase)は、より構造化されたデータアクセスと効率的なキャッシングを提供します。大規模なアプリケーションや複雑なデータ操作に適しています。
- アプローチ2(Request Memoization with fetch)は、シンプルで直感的です。小規模なプロジェクトや、外部APIに依存する場合に適しています。
どちらのアプローチも有効ですが、プロジェクトの要件や規模によって適切な選択が異なります。アプローチ1はより堅牢でスケーラブルですが、セットアップにやや手間がかかります。アプローチ2はシンプルで迅速に実装できますが、より複雑なデータ操作やエラーハンドリングが必要な場合は追加の実装が必要になる可能性があります。
公式のベストプラクティスはこちら
データのfetch層(データアクセス層)を明確に定義しておきましょう
僕ならどうするんだろ、、、
メンターのニノさんに倣ってディレクトリ配下にdata(get用) , actions(create, update, delete 用) ディレクトリを作成するかも
Clean Architechture風味が欲しいのでこちらも
const [a, b] = await Promise.all([
fetch(`XXXX`),
fetch(`XXXX`),
])
この書き方好き
このpreloadパターン賢すぎる・・・・
あらかじめユーザーを取得しておいてMemoizationによってfetch結果をキャッシュしておく、それをそのはいかのCommentで使うのか・・・天才の所業や・・・
supabase利用だと関数にcacheつけておくだけで良さそうなのは嬉しい
なるほど、こんなの知らなかった。DataLoaderね
React Server Componentsとは
React Server Components(RSC)は、Reactアプリケーションの一部をサーバー上でレンダリングする新しいパラダイムです。従来のサーバーサイドレンダリング(SSR)とは異なり、RSCはより細かい粒度でコンポーネントをサーバー上で処理することができます。
RSCの主な特徴
- データフェッチの最適化: サーバー上でデータを取得し、必要な情報のみをクライアントに送信します。
- バンドルサイズの削減: サーバーコンポーネントはクライアントにJavaScriptを送信しないため、overall bundle sizeが削減されます。
- SEOの向上: サーバー上でレンダリングされるため、検索エンジンのクローラーがコンテンツを簡単に解析できます。
- パフォーマンスの向上: 初期ロード時間が短縮され、TTI(Time to Interactive)が改善されます。
RSCの動作原理
RSCは、コンポーネントをサーバー上でレンダリングし、その結果をJSONライクな特殊なフォーマットでクライアントに送信します。クライアント側のReactは、このフォーマットを解釈して最終的なUIを構築します。
以下は、RSCの基本的な動作フローを示す図です:
Server Actionsの概要
Server Actionsは、RSCと密接に関連する機能で、フォームの送信やボタンのクリックなどのユーザーアクションをサーバー上で直接処理することができます。これにより、クライアント側のJavaScriptを最小限に抑えつつ、インタラクティブな機能を実装することが可能になります。
Server Actionsの主な利点
- セキュリティの向上: センシティブな操作をサーバー側で行うことで、クライアント側での改ざんリスクを減らせます。
- パフォーマンスの最適化: ネットワーク往復を減らし、レスポンス時間を短縮できます。
- 開発の簡素化: クライアント側とサーバー側のコードを密接に統合できます。
Server Actionの基本的な使い方
Server Actionは、通常"use server"
ディレクティブを使用して定義します。以下は基本的な例です:
// app/actions.ts
"use server";
export async function handleSubmit(formData: FormData) {
const name = formData.get("name");
// サーバー側でのデータ処理
return `Hello, ${name}!`;
}
このServer Actionは、フォームから送信されたデータを処理し、結果を返します。
useActionStateフックの使い方
useActionState
は、React Server Componentsエコシステムの一部として導入された新しいフックです。このフックを使用することで、Server Actionの結果に基づいてコンポーネントの状態を更新することができます。
useActionStateの基本構文
const [state, formAction] = useActionState(actionFunction, initialState);
-
state
: 現在の状態値 -
formAction
: フォームのaction属性に設定する関数 -
actionFunction
: 実行するServer Action -
initialState
: 初期状態値
useActionStateの使用例
以下は、useActionState
を使用して簡単なカウンターを実装する例です:
// app/actions.ts
"use server";
export async function increment(prevState: number) {
return prevState + 1;
}
// app/Counter.tsx
"use client";
import { useActionState } from "react-dom";
import { increment } from "./actions";
export default function Counter() {
const [count, formAction] = useActionState(increment, 0);
return (
<form action={formAction}>
<p>Count: {count}</p>
<button type="submit">Increment</button>
</form>
);
}
この例では、ボタンをクリックするたびにサーバー側でincrement
関数が実行され、その結果が自動的にcount
状態に反映されます。
実践的な例:商品検索アプリケーション
ここでは、React Server ComponentsとServer Actionsを使用して、簡単な商品検索アプリケーションを構築する方法を段階的に解説します。
ステップ1: プロジェクトのセットアップ
まず、新しいNext.jsプロジェクトを作成します(Next.jsはRSCをサポートしています)。
npx create-next-app@latest product-search-app
cd product-search-app
ステップ2: 必要なファイルの作成
プロジェクト内に以下のファイルを作成します:
-
app/actions.ts
: Server Actionsを定義 -
app/components/SearchForm.tsx
: 検索フォームのコンポーネント -
app/components/ProductList.tsx
: 商品リストのコンポーネント -
app/page.tsx
: メインページ
ステップ3: 型定義
まず、使用する型を定義します。
// app/types.ts
export interface Product {
id: number;
title: string;
description: string;
price: number;
thumbnail: string;
}
ステップ4: Server Actionの実装
// app/actions.ts
"use server";
import { Product } from "./types";
export async function searchProducts(
prevState: Product[],
formData: FormData
): Promise<Product[]> {
const query = formData.get("query") as string;
if (!query) return [];
const res = await fetch(`https://dummyjson.com/products/search?q=${encodeURIComponent(query)}`);
if (!res.ok) {
throw new Error("Failed to fetch products");
}
const data = await res.json();
return data.products;
}
ステップ5: 検索フォームコンポーネントの作成
// app/components/SearchForm.tsx
"use client";
import { useActionState } from "react-dom";
import { searchProducts } from "../actions";
import { Product } from "../types";
export default function SearchForm() {
const [products, formAction] = useActionState<Product[], FormData>(searchProducts, []);
return (
<div>
<form action={formAction}>
<input type="text" name="query" placeholder="Search for products..." />
<button type="submit">Search</button>
</form>
<ProductList products={products} />
</div>
);
}
ステップ6: 商品リストコンポーネントの作成
// app/components/ProductList.tsx
import { Product } from "../types";
interface ProductListProps {
products: Product[];
}
export default function ProductList({ products }: ProductListProps) {
if (products.length === 0) {
return <p>No products found.</p>;
}
return (
<ul>
{products.map((product) => (
<li key={product.id}>
<h3>{product.title}</h3>
<p>{product.description}</p>
<p>Price: ${product.price}</p>
<img src={product.thumbnail} alt={product.title} width="100" />
</li>
))}
</ul>
);
}
ステップ7: メインページの実装
// app/page.tsx
import SearchForm from "./components/SearchForm";
export default function Home() {
return (
<main>
<h1>Product Search</h1>
<SearchForm />
</main>
);
}
これで、基本的な商品検索アプリケーションが完成しました。ユーザーが検索クエリを入力してSubmitすると、Server Actionが実行され、結果がクライアント側の状態に反映されます。
パフォーマンスの最適化
React Server ComponentsとServer Actionsを使用する際、以下のパフォーマンス最適化テクニックを考慮することが重要です:
-
コンポーネントの分割: 大きなコンポーネントを小さな、再利用可能なコンポーネントに分割します。これにより、必要な部分のみを更新することができます。
-
キャッシング:
fetch
リクエストにキャッシングを適用して、同じデータの繰り返しフェッチを防ぎます。const res = await fetch(`https://api.example.com/data`, { next: { revalidate: 3600 } });
-
ページネーション: 大量のデータを扱う場合は、ページネーションを実装して、一度に表示するアイテム数を制限します。
-
遅延ローディング: 重いコンポーネントや大きな画像は、必要になるまでロードを遅らせます。
-
メモ化:
useMemo
やuseCallback
を使用して、不要な再計算や再レンダリングを防ぎます。
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
- Streaming: 大きなページや複雑なUIの場合、ストリーミングを使用して段階的にコンテンツをロードします。
import { Suspense } from "react";
export default function Page() {
return (
<Suspense fallback={<Loading />}>
<SlowComponent />
</Suspense>
);
}
これらの最適化テクニックを適切に組み合わせることで、アプリケーションの応答性とユーザーエクスペリエンスを大幅に向上させることができます。
セキュリティとベストプラクティス
React Server ComponentsとServer Actionsを使用する際は、セキュリティに十分注意を払う必要があります。以下は主要なセキュリティ考慮事項とベストプラクティスです:
-
入力の検証: Server Actionsで受け取るすべての入力データを適切に検証し、サニタイズします。
function validateInput(input: string): boolean { // 入力の検証ロジック return /^[a-zA-Z0-9\s]+$/.test(input); } export async function searchProducts(prevState: any, formData: FormData) { const query = formData.get("query") as string; if (!validateInput(query)) { throw new Error("Invalid input"); } // 以降の処理 }
-
認証と認可: センシティブな操作を行うServer Actionsには、適切な認証と認可のチェックを実装します。
-
CSRF対策: Next.jsなどのフレームワークは通常、CSRF保護を提供していますが、カスタム実装の場合は追加の対策が必要です。
-
エラーハンドリング: サーバーサイドでのエラーを適切に処理し、センシティブな情報が漏洩しないようにします。
try {
const result = await someServerAction(data);
return result;
} catch (error) {
console.error("An error occurred:", error);
return { error: "An unexpected error occurred. Please try again later." };
}
-
環境変数の使用: API キーなどの機密情報は環境変数として管理し、ソースコードにハードコーディングしないようにします。
const apiKey = process.env.API_KEY;
-
最小権限の原則: Server Actionsには必要最小限の権限のみを与え、不要なデータベースアクセスや外部APIコールを避けます。
-
レート制限: DoS攻撃を防ぐため、Server Actionsにレート制限を実装することを検討します。
import { rateLimit } from 'some-rate-limit-library'; const limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100 // 15分あたり最大100リクエストまで }); export async function protectedServerAction(prevState: any, formData: FormData) { await limiter.check(); // レート制限をチェック // アクションの本体 }
-
適切なエラーメッセージ: ユーザーに表示するエラーメッセージは具体的すぎないようにし、攻撃者に有用な情報を与えないようにします。
-
HTTPS の使用: 本番環境では必ずHTTPSを使用し、データの暗号化を確保します。
-
依存関係の管理: 使用しているライブラリやパッケージを定期的に更新し、既知の脆弱性を修正します。
npm audit
npm update
これらのセキュリティプラクティスを適切に実装することで、React Server ComponentsとServer Actionsを使用したアプリケーションのセキュリティを大幅に向上させることができます。
デバッグとトラブルシューティング
React Server ComponentsとServer Actionsを使用する際のデバッグとトラブルシューティングは、従来のReactアプリケーションとは少し異なります。以下に、効果的なデバッグ手法とよくある問題の解決方法を紹介します。
デバッグ技術
-
サーバーサイドログ:
Server Actionsの中でconsole.log
を使用して、サーバーサイドでの処理をログに記録します。export async function serverAction(prevState: any, formData: FormData) { console.log('Server Action called with:', formData); // 処理 console.log('Server Action result:', result); return result; }
-
React DevTools:
React DevToolsを使用して、コンポーネントの階層構造と props の流れを視覚化します。Server Componentsは特別なラベルで表示されます。 -
Network タブ:
ブラウザの開発者ツールのNetworkタブを使用して、Server ComponentsとServer Actionsの通信を監視します。 -
エラーバウンダリ:
エラーバウンダリを使用して、アプリケーション全体がクラッシュするのを防ぎ、エラーを適切に処理します。class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError(error) { return { hasError: true }; } componentDidCatch(error, errorInfo) { console.log('Error caught by boundary:', error, errorInfo); } render() { if (this.state.hasError) { return <h1>Something went wrong.</h1>; } return this.props.children; } }
-
環境変数のチェック:
process.env.NODE_ENV
を使用して、開発環境と本番環境を区別し、環境固有のデバッグロジックを実装します。if (process.env.NODE_ENV !== 'production') { console.log('Debug info:', someData); }
よくある問題と解決策
-
"Server Components cannot be rendered on the client" エラー:
- 原因: クライアントコンポーネント内でサーバーコンポーネントを直接インポートしている。
- 解決策: サーバーコンポーネントは親のサーバーコンポーネントからpropsとして渡す必要があります。
-
Server Actionが呼び出されない:
- 原因: フォームの
action
属性が正しく設定されていない。 - 解決策:
useActionState
から返されるformAction
を正しくフォームに設定しているか確認します。
- 原因: フォームの
-
データが更新されない:
- 原因: キャッシュの問題や
useActionState
の使用方法が誤っている。 - 解決策: キャッシュの設定を確認し、
useActionState
の使用方法が正しいか再確認します。
- 原因: キャッシュの問題や
-
パフォーマンスの問題:
- 原因: 不必要なサーバーリクエストや大きなデータ転送。
- 解決策: キャッシング戦略を見直し、必要最小限のデータのみを転送するようにします。
-
型エラー:
- 原因: TypeScriptの型定義が不適切。
- 解決策: Server ActionsとuseActionStateの型定義を正確に行い、必要に応じて型アサーションを使用します。
デバッグツール
- React Developer Tools: Reactコンポーネントの階層とpropsを視覚化します。
- Chrome DevTools: ネットワーク通信、コンソールログ、パフォーマンスプロファイリングに使用します。
- VS Code Debugger: サーバーサイドコードのステップ実行とブレークポイントの設定に便利です。
まとめ
React Server ComponentsとServer Actionsは、Reactアプリケーションの開発方法を大きく変革する可能性を秘めています。これらの技術を適切に活用することで、以下のような利点が得られます:
- パフォーマンスの向上: サーバーサイドレンダリングとクライアントサイドのインタラクティビティを最適なバランスで組み合わせることができます。
- 開発の簡素化: サーバーサイドとクライアントサイドのコードを密接に統合できるため、開発フローが効率化されます。
- セキュリティの向上: センシティブな操作をサーバーサイドで行うことで、クライアントサイドでの脆弱性リスクを軽減できます。
- SEOの改善: サーバーサイドでレンダリングされるコンテンツが増えるため、検索エンジンのクローラビリティが向上します。
RSC(React Server Components)とハイドレーションの関係
- RSC(React Server Components)の基本
RSCは、サーバー上でレンダリングされ、その結果がクライアントに送信されるコンポーネントです。重要な点は、RSCそのものはクライアントサイドでハイドレーションされない点です。
- ハイドレーションとは
ハイドレーションは、サーバーサイドでレンダリングされたHTMLにJavaScriptのイベントリスナーやステート管理を付加するプロセスです。これは主にクライアントコンポーネントに対して行われます。
- RSCとハイドレーションのタイミング
- RSC自体はハイドレーションされません。
- RSCの中に含まれるクライアントコンポーネントのみがハイドレーションされます。
- ハイドレーションは、ページの初期ロード後にクライアントサイドで行われます。
- RSCとSSR(Server-Side Rendering)の違い
- SSR:全てのコンポーネントをサーバーでレンダリングし、その後クライアントでハイドレーションします。
- RSC:サーバーコンポーネントはサーバーでレンダリングされ、クライアントコンポーネントのみがクライアントでハイドレーションされます。
- ハイドレーションのタイミング
ハイドレーションは以下のタイミングで実行されます:
- ページの初期ロード後
- クライアントコンポーネントが動的にロードされたとき
// ServerComponent.js
export default function ServerComponent({ data }) {
return (
<div>
<h1>Server Component</h1>
<p>This data was fetched on the server: {data}</p>
<ClientComponent />
</div>
);
}
// ClientComponent.js
'use client';
import { useState } from 'react';
export default function ClientComponent() {
const [count, setCount] = useState(0);
return (
<div>
<h2>Client Component</h2>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
// Page.js
import ServerComponent from './ServerComponent';
export default async function Page() {
const data = await fetchDataFromDatabase();
return <ServerComponent data={data} />;
}
この例で、ハイドレーションのプロセスは以下のようになります:
-
Page
コンポーネントがサーバーで実行され、データをフェッチします。 -
ServerComponent
がサーバーでレンダリングされ、その結果がクライアントに送信されます。 -
ClientComponent
の初期状態もサーバーでレンダリングされます。 - クライアントがHTMLを受け取り、表示します。
- JavaScriptがロードされ、
ClientComponent
のみがハイドレーションされます。- この時点で
useState
フックが初期化され、ボタンのクリックイベントがアタッチされます。
- この時点で
ServerComponent
自体はハイドレーションされません。サーバーでレンダリングされた結果がそのまま使用されます。一方、ClientComponent
はクライアントサイドでハイドレーションされ、インタラクティブになります。
このアプローチにより、RSCは初期ロードの高速化とサーバーサイドのデータアクセスの利点を活かしつつ、必要な部分のみをインタラクティブにすることができます。
可能な限りRSCを用いることがベストだと思っていたがそうではないのか
サーバーコンポーネントの利点と特性についての整理
- サーバーコンポーネントの基本的な利点
サーバーコンポーネントを用いることで、サーバー側でコンポーネントを組み立てることができ、これには以下のような利点があります:
- 初期ロード時間の短縮:クライアントに送信するJavaScriptの量を減らせます。
- サーバーリソースの活用:データベースへのアクセスなど、サーバー側の処理を直接行えます。
- SEOの改善:サーバーでレンダリングされるため、検索エンジンがコンテンツを認識しやすくなります。
- パフォーマンスに関する認識
サーバーコンポーネントを使用することで、多くの場合パフォーマンスが向上します。特に:
- 初期ページロード時
- 静的なコンテンツや、頻繁に更新されないコンテンツの場合
- 大規模なアプリケーションで、クライアントサイドのJavaScriptを削減できる場合
- トレードオフとなる状況
しかし、すべての状況でサーバーコンポーネントが最適とは限りません:
- 頻繁に更新が必要なコンテンツの場合、毎回サーバーからデータを取得する必要があります。
- 複雑なインタラクションを含むUIの場合、クライアントサイドでの処理が必要になることがあります。
- RSC Payloadのサイズに関する考慮
前回の説明で触れた「RSC Payloadが大きくなる」という点は、主に以下のような状況を想定しています:
- 非常に大きなデータセットをレンダリングする場合
- 複雑なコンポーネント構造を持つ場合
これらの場合、サーバーコンポーネントを使用してもRSC Payloadが大きくなり、ネットワーク転送のボトルネックとなる可能性があります。
- バランスの取り方
最適なアプローチは、アプリケーションの特性によって異なります:
- 静的なコンテンツや、更新頻度の低いコンテンツ:サーバーコンポーネントが適しています。
- 動的で頻繁に更新されるコンテンツ:クライアントコンポーネントの使用を検討する価値があります。
- 複雑なインタラクション:クライアントサイドでの処理が必要になる場合があります。
結論として、サーバーコンポーネントは多くの場合でパフォーマンスを向上させますが、アプリケーションの特性や要件によっては、サーバーコンポーネントとクライアントコンポーネントを適切に組み合わせることが最適なパフォーマンスにつながる場合があります。
これに関して。
クライアントコンポーネント自体はサーバーコンポーネントを利用することができません。なぜならば、クライアントコンポーネントはブラウザ行されるため、サーバー専用のコードに直接アクセスができないためです。 ではどうすればクライアントコンポーネントでサーバーコンポーネントが使用できるかと言うと、propsを通じて渡すか、もしくは特別な方法でラップすることが必要になります。詳細以下です。
クライアントコンポーネントでサーバーコンポーネントを使用するいくつかのユースケース(Compositionパターン)
// 1. Props を通じてサーバーコンポーネントを渡す
// ServerComponent.tsx
import { getUserData } from '@/lib/userData';
export default async function ServerComponent() {
const userData = await getUserData();
return <div>{userData.name}</div>;
}
// ClientComponent.tsx
'use client';
import { useState } from 'react';
export default function ClientComponent({ ServerComponent }: { ServerComponent: React.ComponentType }) {
const [isVisible, setIsVisible] = useState(false);
return (
<div>
<button onClick={() => setIsVisible(!isVisible)}>Toggle Server Component</button>
{isVisible && <ServerComponent />}
</div>
);
}
// Page.tsx
import ClientComponent from './ClientComponent';
import ServerComponent from './ServerComponent';
export default function Page() {
return <ClientComponent ServerComponent={ServerComponent} />;
}
// 2. 子コンポーネントとしてサーバーコンポーネントを渡す
// Layout.tsx
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div>
<h1>My App</h1>
{children}
</div>
);
}
// ServerComponent.tsx
import { getServerSideData } from '@/lib/data';
export default async function ServerComponent() {
const data = await getServerSideData();
return <div>{data}</div>;
}
// ClientComponent.tsx
'use client';
import { useState } from 'react';
export default function ClientComponent({ children }: { children: React.ReactNode }) {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
{children}
</div>
);
}
// Page.tsx
import Layout from './Layout';
import ClientComponent from './ClientComponent';
import ServerComponent from './ServerComponent';
export default function Page() {
return (
<Layout>
<ClientComponent>
<ServerComponent />
</ClientComponent>
</Layout>
);
}
// 3. 非同期のサーバーコンポーネントを使用する
// AsyncServerComponent.tsx
import { getSomeAsyncData } from '@/lib/asyncData';
export default async function AsyncServerComponent() {
const data = await getSomeAsyncData();
return <div>{data}</div>;
}
// ClientWrapper.tsx
'use client';
import { Suspense } from 'react';
export default function ClientWrapper({ children }: { children: React.ReactNode }) {
return (
<div>
<h2>Client Wrapper</h2>
<Suspense fallback={<div>Loading...</div>}>
{children}
</Suspense>
</div>
);
}
// Page.tsx
import ClientWrapper from './ClientWrapper';
import AsyncServerComponent from './AsyncServerComponent';
export default function Page() {
return (
<ClientWrapper>
<AsyncServerComponent />
</ClientWrapper>
);
}
これらのユースケースについて説明します:
-
Props を通じてサーバーコンポーネントを渡す:
- サーバーコンポーネントを別のファイルで定義し、それをクライアントコンポーネントにpropsとして渡します。
- クライアントコンポーネント内でサーバーコンポーネントを条件付きでレンダリングできます。
-
子コンポーネントとしてサーバーコンポーネントを渡す:
- クライアントコンポーネントが
children
プロップを受け取り、その中にサーバーコンポーネントを含めます。 - これにより、クライアントコンポーネントの状態管理とサーバーコンポーネントのデータフェッチを組み合わせることができます。
- クライアントコンポーネントが
-
非同期のサーバーコンポーネントを使用する:
- 非同期データフェッチを行うサーバーコンポーネントを作成します。
- クライアントコンポーネント内で
Suspense
を使用して、データロード中の表示を制御します。
これらの方法を使うことで、クライアントコンポーネントとサーバーコンポーネントを効果的に組み合わせ、それぞれの利点を活かしたアプリケーションを構築できます。
Client ComponentsからServer Actionsを通じて更新や削除などの操作を直接呼び出したくない
Client ComponentsからServer Actionsを通じて更新や削除操作を直接呼び出すことが望ましくない理由:
-
セキュリティリスク:
- クライアントサイドのコードは、ユーザーが直接アクセスし、改変する可能性があります。
- 悪意のあるユーザーが、クライアントサイドのコードを操作して不正な操作をServer Actionsに送信する可能性があります。
-
認証・認可の問題:
- クライアントサイドでは、ユーザーの認証状態や権限を完全に信頼することができません。
- サーバーサイドで適切な認証・認可チェックを行わないと、権限のないユーザーがデータを改変する可能性があります。
-
データの整合性:
- クライアントサイドでは、最新のサーバーサイドの状態を常に把握しているわけではありません。
- 古いデータに基づいて更新や削除を行うと、データの整合性が損なわれる可能性があります。
-
ビジネスロジックの露出:
- 重要なビジネスロジックをクライアントサイドに露出させることになり、アプリケーションの核心部分が脆弱になります。
-
パフォーマンスとネットワークの問題:
- 頻繁な更新・削除操作は、不必要なネットワークトラフィックを生成し、アプリケーションのパフォーマンスに影響を与える可能性があります。
推奨されるアプローチ:
-
読み取り操作(GET)のみをClient ComponentsからServer Actionsで直接呼び出す。
-
更新・削除操作は、専用のAPIエンドポイントを通じて行う:
- これらのエンドポイントでは、適切な認証・認可チェックを実装する。
- サーバーサイドでデータの整合性チェックを行う。
- 必要なビジネスロジックをサーバーサイドで実行する。
-
フォームの送信やユーザーアクションに基づく更新は、Server ComponentsやServer Actionsを使用して処理する:
- これにより、サーバーサイドで適切なバリデーションと処理を行うことができます。
-
状態管理とデータの同期に注意を払う:
- クライアントサイドの状態とサーバーサイドのデータを適切に同期させる仕組みを設計する。
このアプローチを採用することで、アプリケーションのセキュリティ、データの整合性、パフォーマンスを向上させることができます。また、Server ComponentsとClient Componentsの適切な役割分担を維持することができ、Next.jsアプリケーションの堅牢性を高めることができます。
Client Boundaryの概念を視覚的に表現したフローチャート
このフローチャートは、Client Boundaryの概念を視覚的に表現しています。以下に、図の主要な要素と関係性を説明します:
-
サーバーサイド(青色の領域):
- Root Layout、Page、Header、Main Content、UserInfoなどのServer Componentsが含まれています。
- これらのコンポーネントはサーバー上でレンダリングされます。
-
Client Boundary(赤色の領域):
-
"use client";
ディレクティブで始まるSideMenuコンポーネントから始まります。 - Toggle ButtonやMenu Itemsなどの子コンポーネントも自動的にClient Componentsになります。
-
-
Server Actions(緑色の領域):
-
"use server";
ディレクティブで定義されるサーバーサイドの関数です。 - Client ComponentsからServer Actionsを呼び出すことができます。
-
-
依存関係と相互作用:
- Server ComponentsはClient Componentsを直接importできません。
- しかし、Server Components(例:UserInfo)をClient Components(例:SideMenu)の子要素として渡すことができます(点線の矢印で表現)。
- Client ComponentsはServer Actionsを呼び出すことができます(実線の矢印で表現)。
この図は、Next.jsにおけるServer ComponentsとClient Componentsの関係、およびClient Boundaryの概念を視覚的に示しています。サーバーサイドでのレンダリングとクライアントサイドでのインタラクティブな機能の組み合わせ方、そしてServer Actionsを通じたサーバーサイド処理の呼び出し方を理解するのに役立ちます。
Container / Presentation パターンとは
Container / Presentation パターン(別名: Container / View パターン)は、React アプリケーションの設計パターンの1つです。このパターンの主な目的は、ロジックと表示を分離することで、コンポーネントの再利用性と保守性を高めることです。
主な特徴は以下の通りです:
-
Container コンポーネント:
- データの取得やビジネスロジックを担当
- 状態管理を行う
- 外部データソース(API、Redux storeなど)とのやり取りを行う
- Presentation コンポーネントにデータやコールバック関数を渡す
-
Presentation コンポーネント:
- UIの表示のみを担当
- propsを通じて渡されたデータを表示
- ユーザーインタラクションを処理し、propsで渡された関数を呼び出す
- 基本的に状態を持たない(ただし、UIの状態は例外的に持つ場合もある)
この記事の文脈では、React Server Components (RSC) のテストにおいて、このパターンを活用しています。RSCはサーバーサイドで動作し、非同期処理を含むため、従来のテストツールでは直接テストすることが難しいです。そこで:
- Container: サーバーサイドのロジック(データ取得など)を担当
- Presentation: クライアントサイドで描画されるUI部分を担当
と分離することで、それぞれを独立してテストすることができるようになります。Presentationコンポーネントは通常のReactコンポーネントとして@testing-library/reactやStorybookでテストでき、Containerコンポーネントはサーバーサイドのロジックとしてテストできます。
これにより、RSCを含むアプリケーションでも効果的にテストを書くことが可能になります。
Containerコンポーネントにおける状態管理について
Next.jsの文脈でのContainerコンポーネントにおける状態管理は、主にサーバーサイドの状態やデータの管理を指します。具体的には以下のようなユースケースが考えられます:
- データベースクエリの実行と結果の管理
ユースケース:ユーザープロフィールページ
// app/users/[id]/page.tsx
import { prisma } from '@/lib/prisma'
import ProfilePresentation from './ProfilePresentation'
export default async function UserProfileContainer({ params }: { params: { id: string } }) {
const user = await prisma.user.findUnique({
where: { id: params.id },
include: { posts: true, comments: true }
})
if (!user) {
return <div>User not found</div>
}
return <ProfilePresentation user={user} />
}
このコンテナは、データベースからユーザー情報を取得し、その結果を管理します。
- 外部APIからのデータフェッチと状態管理
ユースケース:天気予報ページ
// app/weather/[city]/page.tsx
import WeatherPresentation from './WeatherPresentation'
async function fetchWeather(city: string) {
const res = await fetch(`https://api.weather.com/${city}`)
return res.json()
}
export default async function WeatherContainer({ params }: { params: { city: string } }) {
const weatherData = await fetchWeather(params.city)
return <WeatherPresentation weather={weatherData} />
}
このコンテナは外部APIからデータをフェッチし、その結果を管理します。
- 複数のデータソースの統合
ユースケース:ダッシュボードページ
// app/dashboard/page.tsx
import { prisma } from '@/lib/prisma'
import DashboardPresentation from './DashboardPresentation'
async function fetchStockData() {
const res = await fetch('https://api.stocks.com/latest')
return res.json()
}
export default async function DashboardContainer() {
const [userCount, latestPosts, stockData] = await Promise.all([
prisma.user.count(),
prisma.post.findMany({ take: 5, orderBy: { createdAt: 'desc' } }),
fetchStockData()
])
return (
<DashboardPresentation
userCount={userCount}
latestPosts={latestPosts}
stockData={stockData}
/>
)
}
このコンテナは複数のデータソース(データベースと外部API)からデータを取得し、それらを統合して管理します。
- サーバーサイドでの計算や集計
ユースケース:売上レポートページ
// app/sales-report/page.tsx
import { prisma } from '@/lib/prisma'
import SalesReportPresentation from './SalesReportPresentation'
export default async function SalesReportContainer() {
const sales = await prisma.sale.findMany()
const totalSales = sales.reduce((sum, sale) => sum + sale.amount, 0)
const averageSale = totalSales / sales.length
const topSellers = sales
.sort((a, b) => b.amount - a.amount)
.slice(0, 5)
return (
<SalesReportPresentation
totalSales={totalSales}
averageSale={averageSale}
topSellers={topSellers}
/>
)
}
このコンテナはデータベースから売上データを取得し、サーバーサイドで集計や計算を行います。
- 認証状態の管理
ユースケース:保護されたページ
// app/admin/page.tsx
import { getServerSession } from 'next-auth/next'
import { redirect } from 'next/navigation'
import AdminDashboardPresentation from './AdminDashboardPresentation'
export default async function AdminDashboardContainer() {
const session = await getServerSession()
if (!session || session.user.role !== 'admin') {
redirect('/login')
}
const adminData = await fetchAdminData(session.user.id)
return <AdminDashboardPresentation adminData={adminData} />
}
このコンテナは認証状態を確認し、必要に応じてリダイレクトを行います。また、認証されたユーザーに基づいてデータを取得します。
これらの例で示したように、Next.jsのコンテキストでのContainerコンポーネントは、主にサーバーサイドでのデータ取得、処理、状態管理を担当します。これにより、Presentationコンポーネントは純粋にUIの表示に集中でき、関心の分離が実現されます。