こんにちは、株式会社ファストコーディングのフルスタックエンジニア、独身貴族Fireです。
週末にギターの練習をしていて気づいたことがあります。コードチェンジが上手い人は「指の動き」を覚えているのではなく、「コード同士のつながり」を覚えている。CからGに行くときに動く指と動かない指のパターンが体に入っている。
Reactのカスタムhooksも同じで、「何をまとめるか」より「何と何がつながっているか」を見極めるのが設計の勘所です。
AI駆動開発でカスタムhooksを設計するとき、AIは「まとめられるものをまとめる」のは得意ですが、「この粒度で分けるべきか」の判断は苦手です。今回は、AIにカスタムhooksの設計を提案させて、人間が粒度と責務を調整した過程を紹介します。
なぜカスタムhooksが必要なのか
コンポーネントが成長すると、ビジネスロジックとUIロジックが混ざり始めます。useStateが5つ、useEffectが3つ並んだコンポーネントは、どこからがUI制御でどこからがデータ操作なのか、読む人が迷います。
カスタムhooksは「ロジックの名前付き抽出」です。コンポーネントからロジックを分離し、テスト可能で再利用可能な単位にまとめる。ただし、やみくもに分離すると「ただ別ファイルに移しただけ」のhooksが量産されます。
分離の基準は「同じ理由で変更されるものをまとめる」です。フォームのバリデーションロジックは、UIの表示方法が変わっても変わらない。データのフェッチロジックは、表示するコンポーネントが変わっても変わらない。この「変更の軸」が異なるものを別のhooksに分ける。
AIに設計を依頼する:フォームのケース
商品検索フォームのコンポーネントを例に、AIにカスタムhooksの設計を依頼しました。元のコンポーネントはuseState が8つ、useEffect が2つ、useCallback が3つ。合計13個のhooksがフラットに並んでいる状態です。
// SearchPage.tsx — hooksが混在した状態
'use client';
import { useState, useEffect, useCallback } from 'react';
type Product = {
id: number;
name: string;
price: number;
category: string;
};
export default function SearchPage() {
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [products, setProducts] = useState<Product[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sortKey, setSortKey] = useState<'name' | 'price'>('name');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
// デバウンス
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timer);
}, [query]);
// データ取得
useEffect(() => {
if (!debouncedQuery) { setProducts([]); return; }
setIsLoading(true);
setError(null);
fetch(`/api/products?q=${encodeURIComponent(debouncedQuery)}&page=${page}`)
.then((res) => {
if (!res.ok) throw new Error('検索に失敗しました');
return res.json();
})
.then((data) => {
setProducts((prev) => (page === 1 ? data.items : [...prev, ...data.items]));
setHasMore(data.hasMore);
})
.catch((err) => setError(err.message))
.finally(() => setIsLoading(false));
}, [debouncedQuery, page]);
// ソート・ハンドラは省略
// ...
}AIの提案:3つのカスタムhooks
AIに設計方針(1つのhooksは1つの責務、引数と戻り値の型を明示、過度な抽象化はしない)を伝えて提案させました。
AIの提案1:useDebounce(採用)
// hooks/useDebounce.ts
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}判断:採用。デバウンスは汎用的なロジックで、検索以外でも使えます。引数はvalueとdelay、戻り値はデバウンス済みの値。責務が明確で粒度も適切です。
AIの提案2:useProductSearch(採用、ただし修正あり)
AIの提案ではuseDebounceをhooksの内部で呼んでいましたが、delayがハードコードされる問題がありました。修正版ではデバウンスをhooksの外に出し、AbortControllerも追加しています。
// hooks/useProductSearch.ts — 修正版
import { useState, useEffect } from 'react';
type Product = {
id: number;
name: string;
price: number;
category: string;
};
type UseProductSearchReturn = {
products: Product[];
isLoading: boolean;
error: string | null;
hasMore: boolean;
loadMore: () => void;
reset: () => void;
};
export function useProductSearch(query: string): UseProductSearchReturn {
const [products, setProducts] = useState<Product[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
useEffect(() => {
if (!query) { setProducts([]); return; }
setIsLoading(true);
setError(null);
const controller = new AbortController();
fetch(`/api/products?q=${encodeURIComponent(query)}&page=${page}`, {
signal: controller.signal,
})
.then((res) => {
if (!res.ok) throw new Error('検索に失敗しました');
return res.json();
})
.then((data) => {
setProducts((prev) => (page === 1 ? data.items : [...prev, ...data.items]));
setHasMore(data.hasMore);
})
.catch((err) => {
if (err.name !== 'AbortError') {
setError(err.message);
}
})
.finally(() => {
if (!controller.signal.aborted) {
setIsLoading(false);
}
});
return () => controller.abort();
}, [query, page]);
const loadMore = () => {
if (hasMore && !isLoading) setPage((prev) => prev + 1);
};
const reset = () => {
setProducts([]);
setPage(1);
setHasMore(true);
setError(null);
};
return { products, isLoading, error, hasMore, loadMore, reset };
}修正のポイントは2つです。
- デバウンスをhooksの外に出した。useProductSearchはデバウンス済みのクエリを受け取る。デバウンスの責務は呼び出し元にある
- AbortControllerを追加した。AIの提案にはなかった。クエリが変わったときに前のリクエストをキャンセルしないと、古い結果が後から返ってきて表示が乱れるリスクがある
AIの提案3:useSort(不採用)
AIは汎用的なソートhooksも提案しましたが、不採用としました。理由は2つあります。
- この粒度ではhooksにする利点が薄い。ソートのロジックはuseStateが2つとuseMemoが1つだけ。コンポーネントに直接書いても十分読める
- 汎用化しすぎている。
keyof Tで任意の型に対応しようとしているが、実際にこのソートロジックを別のコンポーネントで使い回すことはなかった
AIは「分離できるものはすべて分離する」傾向があります。しかし、再利用の予定がないロジックをhooksに分離しても、ファイルが増えるだけです。分離の判断基準は「2回以上使うか」「テストを個別に書きたいか」のどちらかに該当するかどうかです。
最終的なコンポーネント
カスタムhooksを適用した後のSearchPageです。コンポーネント内のuseStateは3つ(query, sortKey, sortOrder)に減り、useEffectは0になりました。
// SearchPage.tsx — hooks分離後
'use client';
import { useState, useMemo, useCallback } from 'react';
import { useDebounce } from '@/hooks/useDebounce';
import { useProductSearch } from '@/hooks/useProductSearch';
export default function SearchPage() {
const [query, setQuery] = useState('');
const [sortKey, setSortKey] = useState<'name' | 'price'>('name');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const debouncedQuery = useDebounce(query, 300);
const { products, isLoading, error, hasMore, loadMore } = useProductSearch(debouncedQuery);
const sortedProducts = useMemo(
() =>
[...products].sort((a, b) => {
const aVal = a[sortKey];
const bVal = b[sortKey];
if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1;
if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1;
return 0;
}),
[products, sortKey, sortOrder]
);
// ... UIレンダリング
}カスタムhooks設計の判断基準
今回の実験を通じて整理した、カスタムhooksに分離するかどうかの判断基準です。
| 基準 | 分離する | コンポーネントに残す |
|---|---|---|
| 再利用性 | 2つ以上のコンポーネントで使う | 1つのコンポーネントでしか使わない |
| テスト | ロジック単体でテストしたい | UIと一緒にテストすれば十分 |
| 複雑さ | useState + useEffect の組み合わせが複雑 | useStateが1〜2個で完結 |
| 変更頻度 | UIの変更とロジックの変更が独立 | UIとロジックが同時に変更される |
AIは上記の「複雑さ」と「再利用性」は判断できますが、「変更頻度」はプロジェクトのコンテキストがないと判断できません。ここが人間の仕事です。
まとめ
今回は、AI駆動開発でReactのカスタムhooksを設計する過程を紹介しました。
カスタムhooksの設計でAIを活用する際のポイントは以下の3つです。
- AIには候補を出させ、人間が粒度を決める。AIは「分離できるもの」を見つけるのは得意だが、「分離すべきかどうか」の判断は人間が行う
- デバウンスの責務はhooksの外に出す。hooksの内部にデバウンスをハードコードすると再利用性が下がる。引数として受け取る設計が望ましい
- AbortControllerを忘れない。AIはfetch処理を提案するが、キャンセル処理を入れないことが多い。リクエストの競合は手動でケアする
私たちの開発現場でも、AIにhooksの候補を出させてから人間がレビュー・調整するフローが定着しています。AIが「こういう分け方ができます」と提案し、人間が「このプロジェクトではこの粒度が最適」と判断する。この協業のバランスが、保守しやすいコードベースにつながっています。
株式会社ファストコーディングでは、AI駆動開発を取り入れたReact/Next.jsの設計・実装をサポートしています。「コンポーネントが肥大化してきた」「hooksの設計方針を整理したい」という方は、お問い合わせフォームからお気軽にご相談ください。

