Skip to content

安全に壊れるための設計原則

「正常に動く」よりも「異常時に安全に壊れる」ことを優先する設計原則を詳しく解説します。

📚 Error Boundaryの詳細: 「安全に壊れる」ためには、Error Boundaryが不可欠です。詳細については、パフォーマンス最適化のError Boundaryを参照してください。

外部(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、インジェクションなどの攻撃を防止
  • 保守性: スキーマ駆動で保守が容易

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. ロジックの隔離: カスタムフックへの隠蔽

hooks/useData.ts
// カスタムフック: その中身が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を参照してください。

mocks/handlers.ts
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を表示させてください。

ベストプラクティス:

  1. 機能単位でError Boundaryを設置: 重要な機能単位でError Boundaryを設置し、一部が壊れても他の機能は生き残るようにする
  2. エラーログを送信: エラーが発生した場合、エラー報告サービス(Sentryなど)に送信する
  3. 再試行機能を提供: ユーザーが再試行できるボタンを提供する
  4. ユーザーフレンドリーなエラーメッセージ: 技術的なエラーメッセージではなく、ユーザーに分かりやすいメッセージを表示する

📚 Error Boundaryの詳細: Error Boundaryの詳細な実装方法については、パフォーマンス最適化のError Boundaryを参照してください。

安全に壊れるための設計原則のポイント:

  • 境界防御: 外部データは常に汚染されていると仮定し、**スキーマ駆動バリデーション(Zod)**で型・形式・範囲を検査
  • 副作用の局所化: 副作用をロジックの末尾に集約し、純粋処理と分離(計算できるものはStateに入れない
  • 依存の隔離: カスタムフックへの隠蔽MSWで抽象化(クラスベースのDIではなく)
  • Error Boundary: コンポーネント単位でError Boundaryを設置し、一部が壊れても他の機能は生き残るようにする

これらの原則により、「異常時に安全に壊れる」堅牢なシステムを構築できます。