React, Vue.js
投稿日:

AI駆動開発でReactのカスタムHooksを設計する ─ 再利用性の高いロジック分離術

こんにちは、株式会社ファストコーディングのフルスタックエンジニア、独身貴族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;
}

判断:採用。デバウンスは汎用的なロジックで、検索以外でも使えます。引数はvaluedelay、戻り値はデバウンス済みの値。責務が明確で粒度も適切です。

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つです。

  1. デバウンスをhooksの外に出した。useProductSearchはデバウンス済みのクエリを受け取る。デバウンスの責務は呼び出し元にある
  2. AbortControllerを追加した。AIの提案にはなかった。クエリが変わったときに前のリクエストをキャンセルしないと、古い結果が後から返ってきて表示が乱れるリスクがある

AIの提案3:useSort(不採用)

AIは汎用的なソートhooksも提案しましたが、不採用としました。理由は2つあります。

  1. この粒度ではhooksにする利点が薄い。ソートのロジックはuseStateが2つとuseMemoが1つだけ。コンポーネントに直接書いても十分読める
  2. 汎用化しすぎている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つです。

  1. AIには候補を出させ、人間が粒度を決める。AIは「分離できるもの」を見つけるのは得意だが、「分離すべきかどうか」の判断は人間が行う
  2. デバウンスの責務はhooksの外に出す。hooksの内部にデバウンスをハードコードすると再利用性が下がる。引数として受け取る設計が望ましい
  3. AbortControllerを忘れない。AIはfetch処理を提案するが、キャンセル処理を入れないことが多い。リクエストの競合は手動でケアする

私たちの開発現場でも、AIにhooksの候補を出させてから人間がレビュー・調整するフローが定着しています。AIが「こういう分け方ができます」と提案し、人間が「このプロジェクトではこの粒度が最適」と判断する。この協業のバランスが、保守しやすいコードベースにつながっています。

株式会社ファストコーディングでは、AI駆動開発を取り入れたReact/Next.jsの設計・実装をサポートしています。「コンポーネントが肥大化してきた」「hooksの設計方針を整理したい」という方は、お問い合わせフォームからお気軽にご相談ください。