はじめに
Reactでアプリケーションを開発する際、適切なコンポーネント設計は保守性と拡張性を大きく左右します。この記事では、小規模アプリケーション(コンポーネント数が10〜30程度、状態管理が比較的シンプルなアプリケーション)における効果的なコンポーネント設計パターンを紹介します。
また、アプリケーションが成長した際に検討すべき設計上の改善点についても触れていきます。
小規模アプリケーションにおける設計の基本原則
小規模アプリケーションでは、シンプルさを保ちながらも、将来の拡張に備えた設計が重要です。以下の3つの原則を押さえておきましょう。
単方向データフロー
Reactの最も重要な設計思想の一つが単方向データフローです。データは常に親コンポーネントから子コンポーネントへpropsとして渡され、子コンポーネントから親への通知はコールバック関数を通じて行います。
// 親コンポーネント
function ParentComponent() {
const [count, setCount] = useState(0);
const handleIncrement = () => {
setCount(prev => prev + 1);
};
return (
<ChildComponent
count={count}
onIncrement={handleIncrement}
/>
);
}
// 子コンポーネント
function ChildComponent({ count, onIncrement }) {
return (
<div>
<p>カウント: {count}</p>
<button onClick={onIncrement}>増やす</button>
</div>
);
}
この設計により、データの流れが予測可能になり、デバッグやメンテナンスが容易になります。
責務の分離
コンポーネントの役割を明確に分けることで、コードの見通しが良くなります。
Appコンポーネントの役割
- アプリケーション全体の状態管理
- 各コンポーネント間の調整
- グローバルな設定の管理
カスタムHooksの役割
- ビジネスロジックの実装
- データの取得と加工
- 状態管理ロジックのカプセル化
UIコンポーネントの役割
- 表示のみに専念
- ユーザー操作の受付
- propsで受け取ったデータの描画
// カスタムHook: ビジネスロジック
function useTaskManager() {
const [tasks, setTasks] = useState([]);
const addTask = (task) => {
setTasks(prev => [...prev, task]);
};
const removeTask = (id) => {
setTasks(prev => prev.filter(task => task.id !== id));
};
return { tasks, addTask, removeTask };
}
// Appコンポーネント: 全体調整
function App() {
const { tasks, addTask, removeTask } = useTaskManager();
return (
<div>
<TaskInput onAdd={addTask} />
<TaskList tasks={tasks} onRemove={removeTask} />
</div>
);
}
// UIコンポーネント: 表示のみ
function TaskList({ tasks, onRemove }) {
return (
<ul>
{tasks.map(task => (
<li key={task.id}>
{task.title}
<button onClick={() => onRemove(task.id)}>削除</button>
</li>
))}
</ul>
);
}
疎結合な設計
各コンポーネントは他のコンポーネントの実装詳細を知る必要がなく、propsのインターフェースのみで通信します。これにより、コンポーネントの独立性が保たれ、変更の影響範囲が限定されます。
// TaskListコンポーネントはtasksの取得方法を知らない
// propsとして受け取るだけ
function TaskList({ tasks, onRemove }) {
// 実装
}
// App側で取得方法を変更しても、TaskListは影響を受けない
function App() {
// APIから取得する場合
const { tasks, removeTask } = useTasksFromAPI();
// またはローカルストレージから取得する場合
// const { tasks, removeTask } = useTasksFromLocalStorage();
return <TaskList tasks={tasks} onRemove={removeTask} />;
}
実装パターンの具体例
コンポーネント構成の全体像
小規模アプリケーションにおける典型的なコンポーネント構成を図で表すと以下のようになります。一例としてTODOアプリを想定しています
この構成では、データフローが明確で、各層の責務が分離されていますね。
カスタムHooksでのロジック分離
カスタムHooksを使うことで、ビジネスロジックとUIを完全に分離できます。これにより、以下のメリットが得られます。
再利用性の向上
同じロジックを複数のコンポーネントで使用できます。
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
// 複数のコンポーネントで再利用可能
function SettingsPage() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
// ...
}
function ProfilePage() {
const [language, setLanguage] = useLocalStorage('language', 'ja');
// ...
}
テスタビリティの向上
ロジックが独立しているため、UIとは別にテストできます。
import { renderHook, act } from '@testing-library/react-hooks';
import { useTaskManager } from './useTaskManager';
test('タスクを追加できる', () => {
const { result } = renderHook(() => useTaskManager());
act(() => {
result.current.addTask({ id: 1, title: 'テスト' });
});
expect(result.current.tasks).toHaveLength(1);
});
中規模・大規模アプリケーションへの移行時の検討点
アプリケーションが成長すると、小規模向けの設計では課題が出てきます。以下のような問題に直面した際は、設計の見直しを検討しましょう。
Props Drillingへの対処
コンポーネントの階層が深くなると、中間のコンポーネントを経由してpropsを渡す必要が出てきます。これを「Props Drilling」と呼びます。
// Props Drillingの例
function App() {
const [user, setUser] = useState(null);
return <Layout user={user} setUser={setUser} />;
}
function Layout({ user, setUser }) {
return (
<div>
<Header user={user} setUser={setUser} />
<Content user={user} />
</div>
);
}
function Header({ user, setUser }) {
return <UserMenu user={user} setUser={setUser} />;
}
// HeaderはuserとsetUserを使わないが、
// UserMenuに渡すためだけに受け取っている
この問題に対しては、Context APIの導入を検討します。
const UserContext = createContext();
function App() {
const [user, setUser] = useState(null);
return (
<UserContext.Provider value={{ user, setUser }}>
<Layout />
</UserContext.Provider>
);
}
function UserMenu() {
// 直接アクセス可能
const { user, setUser } = useContext(UserContext);
return (
// 実装
);
}
導入タイミングの目安
- propsを3階層以上受け渡している
- 同じpropsを複数のコンポーネントツリーで使用している
- 中間コンポーネントがpropsを使用せず、ただ受け渡しているだけ
状態管理ライブラリの検討
アプリケーションの状態が複雑になってきたら、専用の状態管理ライブラリの導入を検討します。
主な選択肢
- Redux: 大規模アプリケーション向け、厳格なルールと豊富なエコシステム
- Zustand: シンプルで軽量、学習コストが低い
- Jotai: Atomベースの状態管理、細かい粒度で管理可能
// Zustandの例
import create from 'zustand';
const useStore = create((set) => ({
tasks: [],
addTask: (task) => set((state) => ({
tasks: [...state.tasks, task]
})),
removeTask: (id) => set((state) => ({
tasks: state.tasks.filter(t => t.id !== id)
})),
}));
function TaskList() {
const { tasks, removeTask } = useStore();
// 実装
}
導入タイミングの目安
- グローバルな状態が10個以上ある
- 状態の更新ロジックが複雑になってきた
- 複数のコンポーネントで同じ状態を参照・更新する
パフォーマンス最適化
コンポーネント数が増えると、不要な再レンダリングがパフォーマンスに影響を与えます。
useCallbackの活用
コールバック関数の再生成を防ぎます。
function ParentComponent() {
const [count, setCount] = useState(0);
// useCallbackを使わない場合、毎回新しい関数が生成される
const handleClick = useCallback(() => {
setCount(prev => prev + 1);
}, []); // 依存配列が空なので、関数は一度だけ生成される
return <ExpensiveChildComponent onClick={handleClick} />;
}
useMemoの活用
計算コストの高い処理の結果をメモ化します。
function TaskList({ tasks, filter }) {
// filterやtasksが変わらない限り、再計算しない
const filteredTasks = useMemo(() => {
return tasks.filter(task => {
// 複雑なフィルタリング処理
return task.status === filter;
});
}, [tasks, filter]);
return (
// 実装
);
}
コンポーネントのメモ化
React.memoを使って、propsが変わらない限り再レンダリングを防ぎます。
const TaskItem = React.memo(function TaskItem({ task, onRemove }) {
return (
<li>
{task.title}
<button onClick={() => onRemove(task.id)}>削除</button>
</li>
);
});
型安全性の強化
TypeScriptを導入することで、propsのインターフェースを明確に定義できます。
// 型定義
interface Task {
id: number;
title: string;
completed: boolean;
}
interface TaskListProps {
tasks: Task[];
onRemove: (id: number) => void;
}
// 型安全なコンポーネント
const TaskList: React.FC<TaskListProps> = ({ tasks, onRemove }) => {
return (
<ul>
{tasks.map(task => (
<li key={task.id}>
{task.title}
<button onClick={() => onRemove(task.id)}>削除</button>
</li>
))}
</ul>
);
};
型定義により、コンパイル時にエラーを検出でき、IDEの補完機能も充実します。
カスタムHooksの粒度調整
カスタムHooksが大きくなりすぎると、逆に複雑になってしまいます。単一責任の原則に従い、適切に分割しましょう。
// 悪い例: 一つのHookに複数の責務
function useTaskApp() {
const [tasks, setTasks] = useState([]);
const [filter, setFilter] = useState('all');
const [sortOrder, setSortOrder] = useState('asc');
// さらに多くの状態とロジック...
// 複雑な処理が詰め込まれている
}
// 良い例: 責務ごとに分割
function useTasks() {
const [tasks, setTasks] = useState([]);
const addTask = (task) => setTasks(prev => [...prev, task]);
const removeTask = (id) => setTasks(prev => prev.filter(t => t.id !== id));
return { tasks, addTask, removeTask };
}
function useTaskFilter() {
const [filter, setFilter] = useState('all');
const applyFilter = (tasks) => {
return tasks.filter(task => {
if (filter === 'all') return true;
return task.status === filter;
});
};
return { filter, setFilter, applyFilter };
}
function useTaskSort() {
const [sortOrder, setSortOrder] = useState('asc');
const sortTasks = (tasks) => {
return [...tasks].sort((a, b) => {
return sortOrder === 'asc'
? a.createdAt - b.createdAt
: b.createdAt - a.createdAt;
});
};
return { sortOrder, setSortOrder, sortTasks };
}
このように分割することで、それぞれのHookが独立してテスト可能になり、再利用性も高まります。
まとめ
小規模アプリケーションでは、以下の基本原則を守ることで、保守性の高いコードを書けます。
- 単方向データフローを徹底する
- 責務を明確に分離する(App、カスタムHooks、UIコンポーネント)
- propsインターフェースで疎結合を保つ
アプリケーションが成長したら、以下の点を検討しましょう。
- Props Drillingが発生したらContext APIを導入
- 状態が複雑になったら状態管理ライブラリを検討
- パフォーマンス問題が出たらメモ化を活用
- TypeScriptで型安全性を強化
- カスタムHooksは適切な粒度で分割
最初から完璧な設計を目指す必要はありません。アプリケーションの成長に合わせて、段階的に設計を改善していくことが重要です。まずは基本原則を押さえた上で、必要に応じて高度なパターンを導入していきましょう。