※本記事はAIを使用して書いています。
Reactで開発を続けていると、機能追加を重ねるにつれて、1つのコンポーネントが少しずつ肥大化していきます。
最初はシンプルだったはずのUIにも、状態管理や副作用、データ加工の処理が集まり、表示のコードとロジックが混在して可読性・保守性が低下していきます。
このガイドでは、こうした課題に対して、Reactのカスタムフックを使ってUIとロジックの責務を分離する方法を解説します。
1. 概要
- テーマ: React における「UI(表示)」と「ロジック/状態(処理)」の責務分離を、カスタムフックで実現する方法を解説します。
- 要点: UI は描画とイベントの受け取りに専念させ、状態管理やビジネスロジックはカスタムフックに集約して再利用性とテスト容易性を高めます。
- 対象: Webアプリ開発の初心者、コードの再利用性・保守性を高めたい開発者。
用語の補足
- カスタムフック(custom hook): React の関数コンポーネントで使う、状態管理や処理を再利用できるようにまとめた関数。命名は use[機能名] で統一します。
2. 動機・背景
Reactの開発では、機能追加を重ねるうちにUIと処理が同じコンポーネントに集まり、変更時の影響範囲が見えにくくなることがあります。
特に、次のような状態になると保守が難しくなります。
- 1つのコンポーネントに、表示・状態管理・入力判定・通信処理など複数の責務が同居している
- 条件分岐がコンポーネント内の複数箇所に散らばり、どの条件がどの表示や処理に効くか追いにくい
- 見た目のコードの中にデータ変換やビジネスロジックが入り込み、UI修正なのかロジック修正なのか判断しづらい
- 副作用(通信・通知・保存など)の実行箇所が分散し、変更時にどこまで影響するか見積もりにくい
このような状態では、可読性や保守性が下がり、修正コストが増えやすくなります。
そこで本ガイドでは、UIとロジックの責務を分離し、機能追加や仕様変更に対応しやすい構成を目指します。
期待される効果は次のとおりです。
- 責務の分離により、1つのコンポーネントへの詰め込みを防ぎ、修正対象を絞りやすくなる
- 条件判定の集約により、どの条件がどの表示・処理に効くかを追いやすくなる
- ビジネスロジックの分離により、表示変更と処理変更を切り分けやすくなる
- 副作用の整理により、変更時の影響範囲を見積もりやすくなる
結果として、可読性と保守性が向上し、テストやリファクタリングもしやすくなります。
3. 提案/主張
ここでは、実装時に意識する方針を3点に絞って示します。
-
UIは表示に専念する
- UIコンポーネントは、表示とイベントの受け取りのみを担当する。
- イベント処理の実装はカスタムフックに任せ、UI側にビジネスロジックを持ち込まない。
- これにより、見た目の変更時に修正対象を絞りやすくなる。
-
ロジックは小さく分離する
- 状態管理とビジネスロジックは、用途別に小さなカスタムフックとして切り出す。
- 必要に応じて、再利用可能な統合フックを作成する。
- フックの粒度は「小さく、再利用可能な機能ごと」に分割する。
-
計算処理はフックの外に出す
- 純粋な計算処理は別関数として分離する。
- フックは「状態と副作用の管理」に専念させる。
- 計算処理を独立させることで、テストしやすく再利用しやすい構成になる。
アンチパターンと対処
実践時に陥りやすいアンチパターンと対処です。
- 詰め込み過ぎたフックを避ける。1つのフックは1つの関心事に絞る。
- 過度な抽象化を避ける。何でもできる曖昧なAPIではなく、用途が名前で分かる具体的なAPIを設計する(例: execute より fetchProjects)。
- カスタムフックでUIを返さない。ロジックのみを扱い、描画はコンポーネント側に分離する。
4. 具体例・ケーススタディ
以下は、UIとロジックを分離する実践例です。
UIは「Game」/「Board」が描画に専念し、ロジックは「useGameHistory」「useBoardGame」という2つのカスタムフックに分離します。
ケーススタディとして、単純な「3目並べゲーム」を例に取ります。
-
ケース前提
- UI:Game.tsx が全体のレイアウトと履歴表示を担当
- ロジック1:useGameHistory.ts は履歴管理と現在の局面の提供
- ロジック2:useBoardGame.ts はクリック処理と現在の状態に基づく表示(状態の解釈)を提供
-
ディレクトリ構造
- src/
- components/
- Board.tsx
- Game.tsx
- hooks/
- index.ts
- useBoardGame.ts
- useGameHistory.ts
- components/
- src/
-
コード例
- src/hooks/useGameHistory.ts
import { useMemo, useState } from 'react';
type Player = 'X' | 'O';
type Squares = (Player | null)[];
function calculateWinner(squares: Squares): Player | null {
// 提案3: 純粋な計算処理をフックの外に分離
const lines = [
[0,1,2], [3,4,5], [6,7,8], // rows
[0,3,6], [1,4,7], [2,5,8], // cols
[0,4,8], [2,4,6] // diagonals
];
for (const [a,b,c] of lines) {
if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
return squares[a];
}
} return null;
}
export function useGameHistory() {
// 提案2: 履歴管理ロジックを小さなフックとして分離
// 履歴を積み重ね、現在の局面を参照する
const [history, setHistory] = useState<Squares[]>([Array(9).fill(null)]);
const [step, setStep] = useState<number>(0);
const currentSquares = history[step];
const xIsNext = step % 2 === 0;
const winner = useMemo(() => calculateWinner(currentSquares), [currentSquares]);
const status = winner
? `Winner: ${winner}`
: `Next player: ${xIsNext ? 'X' : 'O'}`;
const handlePlay = (idx: number) => {
if (currentSquares[idx] || winner) return;
const squares = currentSquares.slice();
squares[idx] = xIsNext ? 'X' : 'O';
const newHistory = history.slice(0, step + 1).concat([squares]);
setHistory(newHistory);
setStep(step + 1);
};
const jumpTo = (move: number) => {
setStep(move);
};
return { history, currentSquares, xIsNext, handlePlay, jumpTo, status };
}
- src/hooks/useBoardGame.ts
type Player = 'X' | 'O';
type Squares = (Player | null)[];
function calculateWinner(squares: Squares): Player | null {
const lines = [
[0,1,2], [3,4,5], [6,7,8],
[0,3,6], [1,4,7], [2,5,8],
[0,4,8], [2,4,6]
];
for (const [a,b,c] of lines) {
if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
return squares[a];
}
}
return null;
}
export function useBoardGame(currentSquares: Squares, xIsNext: boolean, onPlay: (idx: number) => void) {
// 提案2: 盤面操作ロジックを別フックとして分離
const winner = calculateWinner(currentSquares);
const status = winner
? `Winner: ${winner}`
: `Next player: ${xIsNext ? 'X' : 'O'}`;
const handleClick = (idx: number) => {
if (currentSquares[idx] || winner) return;
onPlay(idx);
};
return { handleClick, status };
}
- src/components/Board.tsx
import React from 'react';
type BoardProps = {
squares: (string | null)[];
onPlay: (idx: number) => void;
};
const Board: React.FC<BoardProps> = ({ squares, onPlay }) => {
return (
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(3, 60px)', gap: 8
}}>
{squares.map((val, i) => (
<button
key={i}
onClick={() => onPlay(i)}
style={{ width: 60, height: 60, fontSize: 24 }}
>
{val ?? ''}
</button>
))}
</div>
);
};
export default Board;
- src/components/Game.tsx
import React from 'react';
import Board from './Board';
import { useGameHistory } from '../hooks/useGameHistory';
import { useBoardGame } from '../hooks/useBoardGame';
const Game: React.FC = () => {
// 提案1: UI側はフックを呼び出して描画とイベント受け取りに専念
const { history, currentSquares, xIsNext, handlePlay, jumpTo, status } = useGameHistory();
const { handleClick, status: boardStatus } = useBoardGame(currentSquares, xIsNext, handlePlay);
return (
<div className="game" style={{ display: 'flex' }}>
<div className="game-board" style={{ marginRight: 20 }}>
<Board squares={currentSquares} onPlay={handleClick} />
<div style={{ marginTop: 16 }}>{boardStatus}</div>
</div>
<div className="game-info">
<div>{status}</div>
<ol>
{history.map((_, move) => (
<li key={move}>
<button onClick={() => jumpTo(move)}>
{move > 0 ? `Go to move #${move}` : 'Go to game start'}
</button>
</li>
))}
</ol>
</div>
</div>
);
};
export default Game;
- src/hooks/index.ts
export { useGameHistory } from './useGameHistory';
export { useBoardGame } from './useBoardGame';
-
実装のポイント
- Game.tsx は UI の描画とイベントの受け取りを担当し、履歴表示や現在局面の表示を行う。
- useGameHistory は履歴管理、局面解釈、次プレイヤー判定、勝者判定、表示メッセージ生成を担当する。
- useBoardGame は局面と次プレイヤー情報を基に、クリック可否の判断と表示メッセージ生成を担当する(描画は Board が担当)。
- この分離により、テスト対象をフック中心にでき、再利用性と保守性が高まる。
-
実践のコツ
- コンポーネントは描画とイベント受け取りに専念する。
- 状態管理とビジネスロジックは小さなフックに分ける。
- hooks/index.ts でエクスポートを集約し、インポートを整理する。
5. まとめ・補足
-
要点の整理
- 本ガイドの要点は「UIは表示に専念する」「ロジックは小さく分離する」「計算処理はフックの外に出す」の3点です。
- 4章の例では、Game.tsx / Board.tsx がUI、useGameHistory.ts / useBoardGame.ts がロジック、calculateWinner が計算処理を担当しています。
- この分離により、変更時の影響範囲を把握しやすくなり、フック単位でのテストと再利用がしやすくなります。
-
拡張時の考え方
- 新しい要件を追加する際は、まず「UIの変更」「状態/ビジネスロジックの変更」「計算処理の変更」のどれに当たるかを切り分ける。
- 既存フックに責務を詰め込まず、必要なら用途別のフックや関数を追加して分離を保つと、拡張後も読みやすさを維持しやすい。
補足:MVVMとの対応で捉える
WPFのMVVMに慣れている方は、Reactの責務分離を次のように捉えると理解しやすくなります。
- View:UIコンポーネント(表示とイベントの受け取り)
- ViewModelに相当:カスタムフック(状態管理とロジック)
ReactにはMVVMという公式な定義はありませんが、UIとロジックを分離する設計意図は近く、役割分担を明確にするうえで有効です。
まずは1つの画面で責務を分けてみましょう。変更に強く再利用しやすい設計に着実につながっていきます。