React, Vue.js
投稿日:

AIにReactのカスタムフックを書かせたら品質はどこまでいけるか ─ AI駆動開発の実践レポート

こんにちは、株式会社ファストコーディングのフルスタックエンジニア、独身貴族Fireです。

最近、ギターの練習でコード進行を覚えるのが面倒で、AIに「Key=Amのブルース進行を10パターン出して」と聞いてみたんです。出てきた結果は……まあ、音楽理論的には正しい。でも弾いてみると「これは人が弾いて気持ちいいか?」は別の話でした。

AIって、正しいけど気持ちよくないものを出すのが得意ですよね。コードも同じです。

最近の開発現場では「AI駆動開発」という言葉を頻繁に耳にするようになりました。Claude、Cursor、GitHub Copilotといったツールを使い、コードの設計から実装、テスト、レビューまでをAIと協働で行う開発スタイルです。私自身、ここ半年ほどAI駆動開発を実案件に取り入れていますが、「AIの出力をそのまま使えるか?」は常に議論になります。

そこで今回は、Reactのカスタムフックという比較的スコープが明確なタスクをAIに任せて、出力品質を検証してみます。「フォームバリデーション」「ダークモード切替」「無限スクロール」の3つのフックを題材に、AIの初版コード→問題点→修正後のコードを並べて見ていきます。

まず、Claudeに対してシンプルに「Reactのカスタムフックとして○○を実装してください。TypeScript、関数コンポーネントで使う前提です」とだけ聞いてみます。あえて詳しい指示は出しません。実際の開発でも「とりあえず聞いてみる」から始めることは多いですよね。AIがどこまで汲み取れるかを見るためです。

フォームバリデーション:AIは”動くコード”を出すが、”使えるコード”は出さない

AIが出したコード(初版)

「Reactのカスタムフックとしてフォームバリデーションを実装してください」と聞いた結果がこちらです。

// AIが生成した初版:useFormValidation.ts
import { useState, useCallback } from 'react';

type ValidationRule = {
  required?: boolean;
  minLength?: number;
  maxLength?: number;
  pattern?: RegExp;
  custom?: (value: string) => string | null;
};

type FieldConfig = Record<string, ValidationRule>;
type FormValues = Record<string, string>;
type FormErrors = Record<string, string>;

export function useFormValidation(config: FieldConfig) {
  const [values, setValues] = useState<FormValues>({});
  const [errors, setErrors] = useState<FormErrors>({});

  const validate = useCallback((name: string, value: string): string | null => {
    const rules = config[name];
    if (!rules) return null;

    if (rules.required && !value) return '必須項目です';
    if (rules.minLength && value.length < rules.minLength)
      return `${rules.minLength}文字以上で入力してください`;
    if (rules.maxLength && value.length > rules.maxLength)
      return `${rules.maxLength}文字以下で入力してください`;
    if (rules.pattern && !rules.pattern.test(value))
      return '形式が正しくありません';
    if (rules.custom) return rules.custom(value);

    return null;
  }, [config]);

  const handleChange = useCallback((name: string, value: string) => {
    setValues(prev => ({ ...prev, [name]: value }));
    const error = validate(name, value);
    setErrors(prev => ({ ...prev, [name]: error || '' }));
  }, [validate]);

  const handleSubmit = useCallback((onSubmit: (values: FormValues) => void) => {
    const newErrors: FormErrors = {};
    Object.keys(config).forEach(name => {
      const error = validate(name, values[name] || '');
      if (error) newErrors[name] = error;
    });
    setErrors(newErrors);
    if (Object.keys(newErrors).length === 0) onSubmit(values);
  }, [config, validate, values]);

  return { values, errors, handleChange, handleSubmit };
}

一見よさそう、でも実務では使えない

一見するとまともに見えます。型定義もあるし、バリデーションルールも柔軟。でも実際にプロジェクトに組み込もうとすると、こんな問題が出てきます。

1. 初期値が設定できない
useState<FormValues>({})で初期化しているため、編集フォームのように既存データを表示するケースに対応できません。新規作成フォームだけなら問題ありませんが、実案件では編集画面のほうが圧倒的に多いですよね。

2. configがuseCallbackの依存配列に入っているが、参照が安定しない
呼び出し側がuseFormValidation({ email: { required: true } })のようにインラインでオブジェクトを渡すと、レンダリングのたびにconfigの参照が変わり、validate関数が再生成されます。これはパフォーマンスの問題だけでなく、意図しない再レンダリングの連鎖を引き起こす原因になります。

3. 送信後にフォームをリセットする手段がない

4. touched状態がない
未入力のフィールドにも初回からエラーが表示されてしまいます。ユーザーがまだ触っていないフィールドにエラーを出すのはUXとして問題です。フォームを開いた瞬間に真っ赤なエラーメッセージが並んでいたら、誰だってびっくりしますよね。

修正後のコード

AIの出力をベースに、上記の問題を修正していきます。

// 修正版:useFormValidation.ts
import { useState, useCallback, useRef } from 'react';

type ValidationRule = {
  required?: boolean;
  minLength?: number;
  maxLength?: number;
  pattern?: RegExp;
  message?: string;
  custom?: (value: string) => string | null;
};

type FieldConfig = Record<string, ValidationRule>;
type FormValues = Record<string, string>;
type FormErrors = Record<string, string>;
type TouchedFields = Record<string, boolean>;

export function useFormValidation(
  config: FieldConfig,
  initialValues: FormValues = {}
) {
  const [values, setValues] = useState<FormValues>(initialValues);
  const [errors, setErrors] = useState<FormErrors>({});
  const [touched, setTouched] = useState<TouchedFields>({});
  const configRef = useRef(config);
  configRef.current = config;

  const validate = useCallback((name: string, value: string): string | null => {
    const rules = configRef.current[name];
    if (!rules) return null;

    if (rules.required && !value.trim()) return rules.message || '必須項目です';
    if (rules.minLength && value.length < rules.minLength)
      return `${rules.minLength}文字以上で入力してください`;
    if (rules.maxLength && value.length > rules.maxLength)
      return `${rules.maxLength}文字以下で入力してください`;
    if (rules.pattern && !rules.pattern.test(value))
      return rules.message || '形式が正しくありません';
    if (rules.custom) return rules.custom(value);

    return null;
  }, []);

  const handleChange = useCallback((name: string, value: string) => {
    setValues(prev => ({ ...prev, [name]: value }));
    setTouched(prev => ({ ...prev, [name]: true }));
    const error = validate(name, value);
    setErrors(prev => ({ ...prev, [name]: error || '' }));
  }, [validate]);

  const handleBlur = useCallback((name: string) => {
    setTouched(prev => ({ ...prev, [name]: true }));
    const error = validate(name, values[name] || '');
    setErrors(prev => ({ ...prev, [name]: error || '' }));
  }, [validate, values]);

  const handleSubmit = useCallback((onSubmit: (values: FormValues) => void) => {
    const allTouched: TouchedFields = {};
    const newErrors: FormErrors = {};

    Object.keys(configRef.current).forEach(name => {
      allTouched[name] = true;
      const error = validate(name, values[name] || '');
      if (error) newErrors[name] = error;
    });

    setTouched(allTouched);
    setErrors(newErrors);

    if (Object.keys(newErrors).length === 0) onSubmit(values);
  }, [validate, values]);

  const reset = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
  }, [initialValues]);

  const getFieldError = useCallback((name: string): string => {
    return touched[name] ? errors[name] || '' : '';
  }, [touched, errors]);

  return {
    values,
    errors,
    touched,
    handleChange,
    handleBlur,
    handleSubmit,
    reset,
    getFieldError,
  };
}

修正のポイント:

  • initialValuesを引数に追加し、編集フォームに対応
  • configRefでconfigの参照をuseRefに固定し、不要な再レンダリングを防止
  • touched状態を追加し、getFieldErrorで未タッチフィールドにはエラーを出さない
  • handleBlurを追加(フォーカスが外れたタイミングでバリデーション実行)
  • reset関数を追加

AIの初版は「動くコード」です。しかし「実案件で使えるコード」にするには、UXの観点(touched)、Reactの再レンダリング特性(useRefによるconfig安定化)、運用面(reset、initialValues)の3点で人間の判断が必要になります。

ダークモード切替:「当たり前」をAIは当たり前にやらない

localStorageへの永続化、当たり前だと思いますよね? でもAIは指示しないと入れてくれません。ダークモードの切替フックを見てみます。

AIが出したコード(初版)

// AIが生成した初版:useDarkMode.ts
import { useState, useEffect } from 'react';

export function useDarkMode() {
  const [isDark, setIsDark] = useState(false);

  useEffect(() => {
    if (isDark) {
      document.documentElement.classList.add('dark');
    } else {
      document.documentElement.classList.remove('dark');
    }
  }, [isDark]);

  const toggle = () => setIsDark(prev => !prev);

  return { isDark, toggle };
}

シンプルすぎて実運用に耐えない

コードとしては8行で収まっていて、一見きれいです。ところが、実際のプロダクトで使おうとすると3つの問題にぶつかります。

1. ユーザーの設定がリロードで消える
localStorageを使っていないため、ページを再読み込みすると常にライトモードに戻ります。せっかくダークモードに切り替えたのに、ページ遷移のたびにリセットされたら使い物になりません。

2. OSのダークモード設定を無視している
prefers-color-scheme: darkを読んでいないため、OSをダークモードに設定しているユーザーにも常にライトモードが表示されます。

3. SSR環境でフラッシュが起きる
Next.jsのSSR環境では、サーバーサイドでは常にライトモードでレンダリングされ、クライアントでダークモードに切り替わるため、一瞬白く光る「FOUC(Flash of Unstyled Content)」が発生します。

ギターで言えば、コード進行が正しくても運指が悪ければまともに弾けないのと同じです。「構造は合っている」と「実際に使える」の間には、こういう細かい溝があります。

修正後のコード

// 修正版:useDarkMode.ts
import { useState, useEffect, useCallback } from 'react';

type Theme = 'light' | 'dark' | 'system';

const STORAGE_KEY = 'theme-preference';

function getSystemTheme(): 'light' | 'dark' {
  if (typeof window === 'undefined') return 'light';
  return window.matchMedia('(prefers-color-scheme: dark)').matches
    ? 'dark'
    : 'light';
}

function getStoredTheme(): Theme {
  if (typeof window === 'undefined') return 'system';
  return (localStorage.getItem(STORAGE_KEY) as Theme) || 'system';
}

export function useDarkMode() {
  const [theme, setTheme] = useState<Theme>(() => getStoredTheme());
  const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(getSystemTheme);

  const resolvedTheme = theme === 'system' ? systemTheme : theme;
  const isDark = resolvedTheme === 'dark';

  useEffect(() => {
    const root = document.documentElement;
    root.classList.toggle('dark', isDark);
    root.style.colorScheme = isDark ? 'dark' : 'light';
    localStorage.setItem(STORAGE_KEY, theme);
  }, [isDark, theme]);

  // OSのダークモード設定が変わったときに追従
  useEffect(() => {
    const mql = window.matchMedia('(prefers-color-scheme: dark)');
    const handler = (e: MediaQueryListEvent) => setSystemTheme(e.matches ? 'dark' : 'light');
    mql.addEventListener('change', handler);
    return () => mql.removeEventListener('change', handler);
  }, []);

  const setLight = useCallback(() => setTheme('light'), []);
  const setDark = useCallback(() => setTheme('dark'), []);
  const setSystem = useCallback(() => setTheme('system'), []);
  const toggle = useCallback(() => {
    setTheme(prev => prev === 'dark' || (prev === 'system' && getSystemTheme() === 'dark')
      ? 'light'
      : 'dark'
    );
  }, []);

  return { theme, isDark, setLight, setDark, setSystem, toggle };
}

AIの初版が8行だったのに対し、修正版は50行以上になります。機能が増えたから当然ですが、AIに「ダークモードフック」と言っただけでは、localStorage永続化、OS設定追従、SSR対応の3つが抜けるという知見が得られます。

でも正直、「ダークモードの実装ぐらい自分で書けるのでは」と思ったのは内緒です。AI駆動開発は「自分で書ける人がAIで加速する」ための手法であって、「書けない人がAIに丸投げする」ための手法ではないというのが、半年間の実感です。

無限スクロール:動くけど壊れるコードが一番怖い

無限スクロールのフック、みなさんも一度は実装したことがあるのではないでしょうか。データを追加で読み込んでいく処理は、見た目はシンプルですが罠が多いです。

AIが出したコード(初版)

// AIが生成した初版:useInfiniteScroll.ts
import { useState, useEffect, useCallback } from 'react';

export function useInfiniteScroll<T>(
  fetchFn: (page: number) => Promise<T[]>,
) {
  const [data, setData] = useState<T[]>([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);

  const loadMore = useCallback(async () => {
    setLoading(true);
    const newData = await fetchFn(page);
    setData(prev => [...prev, ...newData]);
    setPage(prev => prev + 1);
    setLoading(false);
  }, [fetchFn, page]);

  useEffect(() => {
    loadMore();
  }, []); // 初回ロード

  useEffect(() => {
    const handleScroll = () => {
      if (
        window.innerHeight + window.scrollY >= document.body.offsetHeight - 500 &&
        !loading
      ) {
        loadMore();
      }
    };
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, [loading, loadMore]);

  return { data, loading };
}

「動く」と「壊れない」は別の話

このコード、ローカルでサッと動かすと一見うまくいきます。ところがプロダクションに載せた瞬間、いくつもの地雷が待っています。

1. fetchFnが依存配列に入っていて無限ループの危険
呼び出し側がインラインで関数を渡すと、レンダリングのたびにloadMoreが再生成→useEffectが再実行→fetchが走る無限ループに陥ります。バイクで言えば、アクセルが戻らなくなるようなものです。見た目は動いていても、すぐに暴走します。

2. hasMoreの概念がない
データが尽きても永遠にfetchし続けます。APIが空配列を返しても、次のページをリクエストし続ける。サーバーに無駄な負荷をかけ続ける設計です。

3. scrollイベントのパフォーマンス
scrollイベントは1秒に60回以上発火します。そのたびに条件判定が走るのは非効率です。

4. エラーハンドリングがない
ネットワークエラーが起きたとき、ユーザーには何も伝わりません。リトライする手段もありません。

正直に言うと、このコードをそのままプルリクエストに出してきたら差し戻します。動くことと壊れないことは、まったく別の話なので。

修正後のコード

// 修正版:useInfiniteScroll.ts
import { useState, useEffect, useCallback, useRef } from 'react';

type UseInfiniteScrollOptions<T> = {
  fetchFn: (page: number) => Promise<T[]>;
  pageSize?: number;
};

export function useInfiniteScroll<T>({
  fetchFn,
  pageSize = 20,
}: UseInfiniteScrollOptions<T>) {
  const [data, setData] = useState<T[]>([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  const fetchFnRef = useRef(fetchFn);
  fetchFnRef.current = fetchFn;

  const loadMore = useCallback(async () => {
    if (loading || !hasMore) return;

    setLoading(true);
    setError(null);

    try {
      const newData = await fetchFnRef.current(page);
      setData(prev => [...prev, ...newData]);
      setHasMore(newData.length >= pageSize);
      setPage(prev => prev + 1);
    } catch (err) {
      setError(err instanceof Error ? err : new Error('データの取得に失敗しました'));
    } finally {
      setLoading(false);
    }
  }, [loading, hasMore, page, pageSize]);

  // 初回ロード
  useEffect(() => {
    loadMore();
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  // IntersectionObserverでスクロール検知
  const sentinelRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    const sentinel = sentinelRef.current;
    if (!sentinel) return;

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && !loading && hasMore) {
          loadMore();
        }
      },
      { rootMargin: '200px' }
    );

    observer.observe(sentinel);
    return () => observer.disconnect();
  }, [loading, hasMore, loadMore]);

  const retry = useCallback(() => {
    setError(null);
    loadMore();
  }, [loadMore]);

  return { data, loading, hasMore, error, sentinelRef, retry };
}

修正のポイントは3つです。

  • fetchFnRefでfetchFnの参照を安定化し、無限ループを防止
  • hasMoreフラグを追加し、取得件数がpageSizeより少なければ「もうデータがない」と判断
  • scrollイベントの代わりにIntersectionObserverを使用。リスト末尾にsentinel要素を配置し、それが画面に入ったときだけfetchを実行する設計に変更

3つのフックから見えたAI駆動開発のリアル

3つのカスタムフックを通じて、AI駆動開発における「人間の役割」が見えてきます。

AIが得意なこと:

  • 基本的な型定義と関数構造の設計
  • 標準的なパターン(useState + useEffect + useCallback)の組み合わせ
  • コードの可読性と命名

AIが苦手なこと:

  • UXの考慮(touched状態、リセット機能)
  • Reactの再レンダリング特性を踏まえた最適化(useRef、依存配列の設計)
  • エッジケース(データ終端、SSR対応、エラーハンドリング)
  • 運用を見据えた設計(永続化、OS設定追従)

結論として、AI駆動開発においてAIは「7割の初版」を出すのが得意で、残り3割の品質を担保するのは人間の仕事です。しかしその「7割の初版」があるのとないのとでは、開発速度がまったく違います。

そして重要なのはレビュー力です。AIの出力を「なんとなく動くからOK」で通すのではなく、「再レンダリングの影響は?」「エッジケースは?」「UXは?」と問える力がAI駆動開発の成否を分けます。

まとめ

今回は、Reactのカスタムフック3つをAIに書かせて品質を検証しました。

  • AIは基本構造を素早く出力できるが、UX・パフォーマンス・エッジケースの考慮が不十分になりがち
  • useRefによる参照安定化、touchedによるUX制御、IntersectionObserverによるパフォーマンス改善など、人間が追加した修正は「Reactの深い理解」に基づくもの
  • AI駆動開発は「7割の初版をAIに出させ、3割のレビュー・修正を人間が行う」協働スタイル
  • プロンプトを詳細にすれば初版の品質は上がるが、それでも本番レベルには人間のレビューが必須

AI駆動開発は、すでに手元で使えるコードを書ける人がさらに速くなるための手法です。「AIが書いたコードのどこがダメか」を見抜ける力がなければ、AIの恩恵は半減します。

株式会社ファストコーディングでは、React/Next.jsを中心としたフロントエンド開発と、AI駆動開発を取り入れた効率的な制作プロセスでプロジェクトを支援しています。「AI駆動開発を自社の開発にも取り入れたい」「React/Next.jsの実装を相談したい」という方は、お問い合わせフォームからお気軽にご連絡ください。