安全に壊れるための設計原則
安全に壊れるための設計原則
Section titled “安全に壊れるための設計原則”「正常に動く」よりも「異常時に安全に壊れる」ことを優先する設計原則を詳しく解説します。
📚 Error Boundaryの詳細: 「安全に壊れる」ためには、Error Boundaryが不可欠です。詳細については、パフォーマンス最適化のError Boundaryを参照してください。
境界防御 (Boundary Defense)
Section titled “境界防御 (Boundary Defense)”外部(API・DOM・ユーザ入力)からのデータは常に汚染されていると仮定し、型・形式・範囲を検査してからロジックに渡す。
【まさかり】スキーマ駆動バリデーション: Vanilla JSのような手動チェックをコンポーネント内に書くのは非効率です。現代のReact開発では**「スキーマ駆動バリデーション」が標準です。ZodやValibotのようなライブラリを使い、「型定義とバリデーションを一体化」**させるべきです。
// ❌ 悪い例: 無防備な入力受付function Component({ data }: { data: any }) { // 問題: 型チェックなし、バリデーションなし return <div>{data.value + data.amount}</div>;}
// ❌ 力技すぎる例: 手動バリデーション(保守不能)interface ComponentProps { data: { value: number; amount: number; };}
function validateProps(props: unknown): ComponentProps { if (typeof props !== 'object' || props === null) { throw new Error('Invalid props'); }
const obj = props as Record<string, unknown>;
if (typeof obj.data !== 'object' || obj.data === null) { throw new Error('Invalid data'); }
const data = obj.data as Record<string, unknown>;
if (typeof data.value !== 'number' || typeof data.amount !== 'number') { throw new Error('Invalid data types'); }
return { data: { value: data.value, amount: data.amount, }, };}
function Component(props: unknown) { const validated = validateProps(props); return <div>{validated.data.value + validated.data.amount}</div>;}問題点:
- 手動チェックが煩雑で保守不能
- 型定義とバリデーションが分離している
- スキーマが増えるたびにコードが肥大化する
✅ 実務的な境界防御: スキーマ駆動バリデーション(Zod)
import { z } from 'zod';
// スキーマ定義: 型定義とバリデーションが一体化const DataSchema = z.object({ value: z.number(), amount: z.number().positive(), // バリデーションルールも定義});
type Data = z.infer<typeof DataSchema>;
function Component({ rawData }: { rawData: unknown }) { // パースに失敗したら「安全に壊れる(Error Boundaryへ投げる)」 const data = DataSchema.parse(rawData);
return <div>{data.value + data.amount}</div>;}利点:
- 型定義とバリデーションの一体化: スキーマを定義するだけで、型とバリデーションが自動生成される
- 保守性: スキーマが増えてもコードがシンプル
- エラーメッセージ: 詳細なエラーメッセージが自動生成される
- 再利用性: スキーマは複数の場所で再利用可能
実践例: エラーハンドリングとError Boundary
import { z } from 'zod';import { ErrorBoundary } from 'react-error-boundary';
const DataSchema = z.object({ value: z.number(), amount: z.number().positive(),});
function DataComponent({ rawData }: { rawData: unknown }) { // パースに失敗したらErrorをthrow(Error Boundaryがキャッチ) const data = DataSchema.parse(rawData);
return <div>{data.value + data.amount}</div>;}
function ErrorFallback({ error, resetErrorBoundary }: { error: Error; resetErrorBoundary: () => void;}) { return ( <div className="error-boundary"> <h2>データの読み込みに失敗しました</h2> <p>データの形式が正しくありません。</p> <button onClick={resetErrorBoundary}>再試行</button> </div> );}
function App() { const rawData: unknown = { value: 100, amount: 50 };
return ( <ErrorBoundary FallbackComponent={ErrorFallback}> <DataComponent rawData={rawData} /> </ErrorBoundary> );}なぜ重要か:
- 型安全性: TypeScriptの型システムで型チェック
- バリデーション: 形式・範囲を検査(Zodで自動生成)
- セキュリティ: XSS、インジェクションなどの攻撃を防止
- 保守性: スキーマ駆動で保守が容易
副作用の局所化
Section titled “副作用の局所化”DOM更新・API呼び出し・ストレージ操作などの副作用をロジックの末尾に集約し、それ以前を状態を持たない純粋処理として保つ。
【まさかり】Stateの同期アンチパターン: Propsから派生するデータ(filter結果など)を
useState+useEffectで管理すると、**「2回レンダリング」**が発生し、パフォーマンス低下とバグの温床になります。「計算できるものはStateに入れない」。まずは「純粋な計算」としてrender関数内で完結させるのがReactの基本です。
// ❌ 悪い例: 副作用が散在function Component({ items }: { items: Item[] }) { const [filtered, setFiltered] = useState<Item[]>([]);
useEffect(() => { // 副作用1: フィルタリング const filtered = items.filter(item => item.active); setFiltered(filtered);
// ビジネスロジック(副作用が混在) if (filtered.length > 100) { // 副作用2: ストレージ操作 localStorage.setItem('largeData', JSON.stringify(filtered)); }
// 副作用3: DOM更新 document.title = `Items: ${filtered.length}`; }, [items]);
return <div>{filtered.length}</div>;}❌ Stateの同期アンチパターン: Propsから派生するデータをuseState + useEffectで管理
function Component({ items }: { items: Item[] }) { const [filtered, setFiltered] = useState<Item[]>([]);
useEffect(() => { // 問題: Propsから派生するデータをStateで管理 // これにより「2回レンダリング」が発生する const filtered = items.filter(item => item.active); setFiltered(filtered); }, [items]);
return <div>{filtered.length}</div>;}
// 問題点:// 1. 2回レンダリングが発生(初回レンダリング → useEffect → setState → 再レンダリング)// 2. パフォーマンスの低下// 3. バグの温床(StateとPropsの不整合の可能性)✅ 良い例: 副作用の局所化(計算できるものはStateに入れない)
function filterItems(items: Item[]): Item[] { // 純粋関数: 副作用なし return items.filter(item => item.active);}
function Component({ items }: { items: Item[] }) { // 1. 純粋な計算(メモ化で最適化) const filtered = useMemo(() => filterItems(items), [items]);
// 2. 副作用はイベントやuseEffectで「外」へ useEffect(() => { // 副作用の集約: すべての副作用を末尾に saveToStorageIfNeeded(filtered); updateDocumentTitle(filtered.length); }, [filtered]);
return <div>{filtered.length}</div>;}
function saveToStorageIfNeeded(items: Item[]) { if (items.length > 100) { localStorage.setItem('largeData', JSON.stringify(items)); }}
function updateDocumentTitle(count: number) { document.title = `Items: ${count}`;}重要なポイント:
- 計算できるものはStateに入れない: Propsから派生するデータは、
useMemoで計算する - 副作用の局所化: 副作用は
useEffectやイベントハンドラーで「外」へ分離 - 純粋関数の優先: まずは純粋な計算としてrender関数内で完結させる
なぜ重要か:
- パフォーマンス: 不要な再レンダリングを防ぐ
- テスト容易性: 純粋関数は単体テストが容易
- 可読性: 副作用が明確に分離される
- デバッグ容易性: 副作用の発生箇所が明確
- バグの防止: StateとPropsの不整合を防ぐ
ビジネスロジックが特定ライブラリやReactの仕様に依存しないよう、抽象化する。
【まさかり】Reactの世界での依存の隔離: クラスやインターフェースを明示的に注入するオブジェクト指向的なDI(依存性注入)は、コードを複雑にするだけで敬遠されがちです。Reactにおける「隔離」の正解は**「カスタムフックへの隠蔽」と「MSW(Mock Service Worker)」**です。
❌ アンチパターン: Java/C#的な依存性注入
Section titled “❌ アンチパターン: Java/C#的な依存性注入”// ❌ 問題のある例: クラスベースのDI(Java/C#的)interface DataRepository { fetchData(): Promise<Data[]>;}
class ApiDataRepository implements DataRepository { async fetchData(): Promise<Data[]> { const res = await fetch('/api/data'); return res.json(); }}
class MockDataRepository implements DataRepository { async fetchData(): Promise<Data[]> { return [{ id: 1, name: 'Mock Data' }]; }}
function useData(repository: DataRepository) { const [data, setData] = useState<Data[]>([]);
useEffect(() => { repository.fetchData().then(setData); }, [repository]);
return data;}
function Component() { const repository = new ApiDataRepository(); const data = useData(repository);
return <div>{data.length}</div>;}
// 問題点:// - クラスベースのDIはReactでは敬遠されがち// - コードが複雑になる// - コンポーネントにrepositoryのインスタンス化が必要✅ 実務的な解決策: カスタムフックとMSW
Section titled “✅ 実務的な解決策: カスタムフックとMSW”1. ロジックの隔離: カスタムフックへの隠蔽
// カスタムフック: その中身がfetchなのかaxiosなのかをコンポーネントに知らせないexport function useData() { const [data, setData] = useState<Data[]>([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState<Error | null>(null);
useEffect(() => { setIsLoading(true); setError(null);
fetch('/api/data') .then(response => { if (!response.ok) { throw new Error('Failed to fetch'); } return response.json(); }) .then(data => { setData(data); setIsLoading(false); }) .catch(error => { setError(error); setIsLoading(false); }); }, []);
return { data, isLoading, error };}
// コンポーネント: カスタムフックを使用function Component() { const { data, isLoading, error } = useData();
if (isLoading) return <div>Loading...</div>; if (error) return <div>Error: {error.message}</div>;
return <div>{data.length}</div>;}2. テストの隔離: MSW(Mock Service Worker)
📚 MSWの詳細: MSWの詳細については、設計ガイドのMSWを参照してください。
import { http, HttpResponse } from 'msw';
export const handlers = [ http.get('/api/data', () => { return HttpResponse.json([ { id: 1, name: 'Mock Data 1' }, { id: 2, name: 'Mock Data 2' }, ]); }),];
// テスト: コードをモック用に書き換えるのではなく、ネットワーク層でモックimport { render, screen, waitFor } from '@testing-library/react';import { setupServer } from 'msw/node';import { handlers } from '../mocks/handlers';import Component from './Component';
const server = setupServer(...handlers);
beforeAll(() => server.listen());afterEach(() => server.resetHandlers());afterAll(() => server.close());
test('displays data', async () => { render(<Component />);
// MSWがモックレスポンスを返すまで待つ await waitFor(() => { expect(screen.getByText(/mock data/i)).toBeInTheDocument(); });});利点:
- 実装詳細をテストしない: カスタムフックの中身(fetch/axios)を知らせない
- ネットワーク層でモック: コードを書き換えずに、ネットワーク層でモック
- RTLの思想に合致: 「実装詳細をテストしない」というReact Testing Libraryの思想に合致
- 保守性: カスタムフックの実装が変わっても、コンポーネントは変更不要
なぜ重要か:
- 交換容易性: APIを変更してもビジネスロジックは変更不要(カスタムフック内で完結)
- テスト容易性: MSWで簡単にテスト可能(実装詳細をテストしない)
- 保守性: フレームワークの変更に強い(カスタムフックで抽象化)
- Reactらしさ: クラスベースのDIではなく、カスタムフックで抽象化
Error Boundary: 安全に壊れるための必須要素
Section titled “Error Boundary: 安全に壊れるための必須要素”【まさかり】「安全に壊れる」ための最大の欠落: 境界防御で
throw Errorしても、それを受け止めるError Boundaryがなければ、アプリ全体が真っ白(ホワイトアウト)になります。これは「安全な壊れ方」ではありません。
コンポーネント単位でError Boundaryを設置し、一部が壊れても他の機能(メニューやナビゲーション)は生き残るようにすることが、「安全に壊れる」ための最大の原則です。
import { ErrorBoundary } from 'react-error-boundary';import { z } from 'zod';
const DataSchema = z.object({ value: z.number(), amount: z.number().positive(),});
function DataComponent({ rawData }: { rawData: unknown }) { // パースに失敗したらErrorをthrow(Error Boundaryがキャッチ) const data = DataSchema.parse(rawData);
return <div>{data.value + data.amount}</div>;}
function ErrorFallback({ error, resetErrorBoundary }: { error: Error; resetErrorBoundary: () => void;}) { return ( <div className="error-boundary"> <h2>データの読み込みに失敗しました</h2> <p>データの形式が正しくありません。</p> <p>エラー詳細: {error.message}</p> <button onClick={resetErrorBoundary}>再試行</button> </div> );}
function App() { const rawData: unknown = { value: 100, amount: 50 };
return ( <div> {/* ナビゲーション: Error Boundaryの外側(常に表示される) */} <Navigation />
{/* データコンポーネント: Error Boundaryで囲む(壊れても他は生き残る) */} <ErrorBoundary FallbackComponent={ErrorFallback} onError={(error, errorInfo) => { // エラーログを送信(Sentryなど) console.error('Error caught by boundary:', error, errorInfo); }} > <DataComponent rawData={rawData} /> </ErrorBoundary>
{/* フッター: Error Boundaryの外側(常に表示される) */} <Footer /> </div> );}設計レビューでの指摘文例:
【指摘】ホワイトアウトのリスクがあります。【問題】バリデーションエラー時にErrorをthrowしていますが、キャッチする仕組みがありません。【影響】アプリ全体がクラッシュし、ユーザーが操作不能になります。【推奨】重要な機能単位でError Boundaryを設置し、フォールバックUIを表示させてください。ベストプラクティス:
- 機能単位でError Boundaryを設置: 重要な機能単位でError Boundaryを設置し、一部が壊れても他の機能は生き残るようにする
- エラーログを送信: エラーが発生した場合、エラー報告サービス(Sentryなど)に送信する
- 再試行機能を提供: ユーザーが再試行できるボタンを提供する
- ユーザーフレンドリーなエラーメッセージ: 技術的なエラーメッセージではなく、ユーザーに分かりやすいメッセージを表示する
📚 Error Boundaryの詳細: Error Boundaryの詳細な実装方法については、パフォーマンス最適化のError Boundaryを参照してください。
安全に壊れるための設計原則のポイント:
- 境界防御: 外部データは常に汚染されていると仮定し、**スキーマ駆動バリデーション(Zod)**で型・形式・範囲を検査
- 副作用の局所化: 副作用をロジックの末尾に集約し、純粋処理と分離(計算できるものはStateに入れない)
- 依存の隔離: カスタムフックへの隠蔽とMSWで抽象化(クラスベースのDIではなく)
- Error Boundary: コンポーネント単位でError Boundaryを設置し、一部が壊れても他の機能は生き残るようにする
これらの原則により、「異常時に安全に壊れる」堅牢なシステムを構築できます。