【テクニカル・上級編】 カスタムフックへの状態ロジックの抽出と再利用 – React実践ガイド

状態の魔窟から抜け出すための流儀:カスタムフックによるロジック抽出の極意

こんにちは。日夜コードベースの肥大化と戦い続けるフロントエンド・アーキテクトの皆さん。

ふと気づけば、ひとつのコンポーネントが500行を超え、`useState` が乱立し、非同期のAPIコールとローカルステートがスパゲッティのように絡み合っている……そんな悪夢のようなコードに直面したことはないだろうか。私は何度も見てきた。そして、その度に頭を抱えてきた。

「コンポーネントはUIの記述に専念し、状態管理ロジックは綺麗に分離すべきだ」という黄金律は誰もが知っている。だが、実際にそれを実務レベルの堅牢さで、しかもパフォーマンスを犠牲にせずに実装できているチームはどれほどあるだろうか?

今回は、単なる「コードの引っ越し」に留まらない、ブラウザのレンダリングパイプラインとReactの内部ファイバー(Fiber)構造を見据えた、極めて実践的な「カスタムフックによる状態ロジックの抽出と再利用」の極意について語り尽くそう。

—

1. なぜ「コンポーネントの肥大化」はパフォーマンスを殺すのか

Reactのコンポーネントが肥大化すると何が起きるか。単に見通しが悪くなるだけではない。最大の悪影響は「無駄なレンダリングの連鎖」と「メモリの不効率な専有」だ。

例えば、ユーザーの入力フォームの状態管理、非同期のバリデーション、そしてサーバーとの同期ロジックが1つのコンポーネントにベタ書きされているとする。ユーザーが一文字入力するたびにコンポーネント全体が再評価(Re-render)され、内部で定義された無数のハンドラー関数が毎回生成され直す。

// ⚠️ 多くの人がやりがちな「アンチパターン」の縮図
function UserProfile() {
const [name, setName] = useState(”);
const [email, setEmail] = useState(”);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

// 入力のたびに再生成されるハンドラー
const handleNameChange = (e) => {
setName(e.target.value);
// ここに複雑なバリデーションやログ送信が混ざり込む…
};

// 肥大化したエフェクトと非同期処理
useEffect(() => {
// 競合状態(Race Condition)の対策も漏れがち
}, [name]);

return (


{/ 膨大なJSX /}

);
}

このコードの何がヤバいか? 状態とそれが依存する副作用(Side Effects)が密結合しているため、Reactのコンポーネントツリーの最適化(`React.memo` や `useMemo`)が完全に機能不全に陥るのだ。

ここで、状態管理ロジックを純粋なJavaScriptの関数――すなわち「カスタムフック」として完全に切り離す必要がある。

—

2. 実務で使える堅牢なカスタムフックの設計原則

カスタムフックを作る際、単に `useState` と `useEffect` を別のファイルにコピペするだけでは、アマチュアの域を出ない。プロのアーキテクトが意識すべきは以下の3点だ。

1. 関心事の完全なカプセル化: UIが知るべきは「データ」と「それを操作する関数」のみ。非同期の競合(Race Condition)やクリーンアップの処理はフックの内部に隠蔽する。
2. 参照の安定性(Referential Transparency): 返す関数群は必ず `useCallback` でメモ化し、無用な子コンポーネントの再レンダリングを防ぐ。
3. 型安全性の極限追求: TypeScriptのジェネリクスを駆使し、呼び出し側で一切の型推論の迷いが生じないようにする。

では、実際に非同期通信、競合状態の回避、そしてローカルステートの最適化を内包した「最高にタフなカスタムフック」の実装を見てみよう。

—

3. 実装例:非同期の競合を防ぐ堅牢なデータ取得フック

実務で最も頻繁に遭遇するバグの一つが、非同期処理のレスポンスが順序通りに戻ってこないことによる「競合状態(Race Condition)」だ。素朴な `useState` と `useEffect` の組み合わせでは、この防壁を構築するのは難しい。

これをカスタムフックとしてエレガントに抽出し、再利用可能にしたコードがこれだ。

import { useState, useEffect, useCallback, useRef } from ‘react’;

// フックの返却値の型定義
interface UseAsyncStateReturn {
data: T | null;
loading: boolean;
error: Error | null;
execute: (…args: any[]) => Promise;
reset: () => void;
}

/

  • 競合状態の防止とクリーンアップを内包した非同期状態管理フック
  • @param asyncFunction 実行したい非同期関数(APIコールなど)

/
export function useAsyncState(
asyncFunction: (…args: any[]) => Promise
): UseAsyncStateReturn {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

// アンマウント後の状態更新を防ぐためのRefガード
const isMountedRef = useRef(true);

useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);

// 処理の実行関数
const execute = useCallback(
async (…args: any[]) => {
setLoading(true);
setError(null);

try {
const result = await asyncFunction(…args);

// コンポーネントがすでにアンマウントされている場合は状態を更新しない
if (isMountedRef.current) {
setData(result);
}
} catch (err) {
if (isMountedRef.current) {
setError(err instanceof Error ? err : new Error(String(err)));
}
} finally {
if (isMountedRef.current) {
setLoading(false);
}
}
},
[asyncFunction]
);

// 状態のリセット関数
const reset = useCallback(() => {
setData(null);
setError(null);
setLoading(false);
}, []);

return { data, loading, error, execute, reset };
}

この設計が優れている理由

1. `isMountedRef` によるメモリリーク・警告の完全撃破: 非同期処理の途中でコンポーネントが破棄された場合でも、Reactの「Can’t perform a React state update on an unmounted component」という忌々しい警告やメモリリークを未然に防ぐ。
2. `useCallback` による関数の不変性: 呼び出し元のコンポーネントが再レンダリングされても、`execute` や `reset` の参照アドレスは変わらない。これにより、これらを依存配列に持つ他のフックや `useEffect` の暴走を防ぐことができる。
3. 関心事の分離: UIコンポーネント側は、APIがどう通信しているかを知る必要がなくなり、「`loading` が true の間はスネークスピナーを回す」といった純粋なUIの表現に集中できる。

—

4. 抽出したフックをコンポーネントでどう手なづけるか

では、先ほど作成したカスタムフックを実際の画面コンポーネントでどのように利用するか。コードの美しさに注目してほしい。

import React, { useEffect } from ‘react’;
import { useAsyncState } from ‘./useAsyncState’;

// 外部のAPIクライアント(仮)
const fetchUserProfile = async (userId: string) => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error(‘ユーザー情報の取得に失敗しました’);
return response.json();
};

interface UserProfileProps {
userId: string;
}

export const UserProfileView: React.FC = ({ userId }) => {
// カスタムフックを呼び出すだけで、複雑な非同期状態が手に入る
const { data: user, loading, error, execute } = useAsyncState(fetchUserProfile);

// マウント時、またはuserIdの変更時にデータを取得
useEffect(() => {
execute(userId);
}, [userId, execute]);

if (loading) return

Loading architecture…

;
if (error) return

Error: {error.message}

;
if (!user) return null;

return (

{user.name}

{user.email}

);
};

見てのとおり、コンポーネントから「非同期の状態管理やエラーハンドリングの泥臭いコード」が完全に消え去り、極めて宣言的で美しいコードベースに生まれ変わった。これが、シニアエンジニアが目指すべきアーキテクチャの姿だ。

—

5. チーフアーキテクトからの最終提言

カスタムフックによる状態ロジックの抽出は、単なる「リファクタリングのテクニック」ではない。それは、アプリケーションのスケーラビリティとメンテナンス性の基盤そのものだ。

ロジックをフックとしてカプセル化しておけば、将来的に状態管理の裏側の実装(例えば、ReactのローカルステートからZustandやReact Queryへの移行など)を変更したくなったときも、UIコンポーネント側のコードを1行も変更せずに、フックの内部実装だけを差し替えることが可能になる。

「コンポーネントを薄く、ロジックを厚く(かつ独立して)」。

この鉄則を胸に、君たちのコードベースからスパゲッティを一掃してほしい健闘を祈る。

コメント

タイトルとURLをコピーしました