8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

React Hooksの登場により、関数コンポーネントでも状態管理やライフサイクル処理が可能になりました。しかし、「useEffectの依存配列がよくわからない」「カスタムフックの作り方がわからない」という声をよく聞きます。

この記事では、基本的なHooksから実践的なカスタムフックの作成まで、具体的なコード例とともに詳しく解説します。

ただ 実務でハマるポイントは

  • useEffectが無限ループする 処理が二重に走る
  • 依存配列の意味が分からず eslintを黙らせるだけになる
  • 状態が散らばって リファクタが効かない

のような 設計の問題 です。
HooksはAPIを覚えるだけではなく
状態と副作用をどう切るか を理解すると一気に安定します。

まず押さえる Hooksを事故らせない前提

この先の理解が早くなるように、最初に地図を置きます。

  • 値はレンダーごとに再計算される。レンダー間で保持したいものだけが state / ref
  • effectは「描画の結果」と「外部世界」を整合させるための同期
  • 依存配列は最適化スイッチではなく、このeffectが参照する値の宣言

実務で詰まるのは、useEffectの使い方そのものよりも「どの問題を state で解き、どの問題を effect で解くか」の切り分けです。

いつstateを増やすべきか 減らすべきか

stateはUIの状態遷移を表す最小集合に寄せます。

  • 入力中の値、選択中のタブ、モーダルの開閉など UIそのもの: state
  • 既存stateから計算できる派生値(合計、表示用整形、フィルタ済み配列): 計算する

派生値をstateに入れると「二重の真実」になり、更新漏れが起きやすいです。

ありがちな落とし穴と対処

  • effectでstateを更新して無限ループ
    • まず「そのstateは本当に必要か」を疑う。派生なら計算に寄せる
  • eslintの依存配列警告を無視する
    • 入れたくない値を参照している設計が原因。関数の持ち上げ、useCallback、引数化で解く
  • StrictModeでeffectが二重に走って壊れる
    • 開発時は副作用が二度起きても壊れない実装にする(購読解除、idempotent)
  • 子に渡す関数が毎回変わって再レンダー
    • まずは計測してから。必要な場面だけuseCallbackとReact.memoを使う

最小アンチパターン集(NG/OKで覚える)

ここで紹介するのは「全Hooks共通で頻出」の最小セットです。
useEffect 特有の事故(fetchの競合、cleanup漏れ、StrictMode二重実行など)は、後半の「useEffectの最小アンチパターン集」も合わせて見ると一気に解像度が上がります。

1) 派生stateを持つ(同期漏れで壊れる)

// NG: 派生値をstateにコピー
const [items, setItems] = useState<Item[]>([]);
const [total, setTotal] = useState(0);
useEffect(() => {
    setTotal(items.reduce((s, x) => s + x.price, 0));
}, [items]);

// OK: その場で計算(必要ならuseMemo)
const total2 = useMemo(() => items.reduce((s, x) => s + x.price, 0), [items]);

2) 「一度だけ実行したい」から [](参照している値がある)

// NG: userIdを参照しているのにdepsが空
useEffect(() => {
    fetch(`/api/users/${userId}`);
}, []);

// OK: 参照した値を宣言する
useEffect(() => {
    fetch(`/api/users/${userId}`);
}, [userId]);

3) propsでオブジェクト/関数を毎回作って渡す(memoが効かない)

// NG
<Child onClick={() => doSomething(id)} config={{ id, mode }} />

// OK(必要な場合)
const onClick = useCallback(() => doSomething(id), [id]);
const config = useMemo(() => ({ id, mode }), [id, mode]);
<Child onClick={onClick} config={config} />

4) refでUIを更新しようとする

// NG: refの変更では再レンダーされない
const countRef = useRef(0);
countRef.current++;
return <div>{countRef.current}</div>; // 期待通りに更新されない

// OK: UIに出すならstate
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;

レビュー用チェックリスト

  • stateに「派生値」や「冗長なコピー」を持っていない
  • effectは外部世界との同期だけに使っている
  • 依存配列から値を抜くのではなく、参照しない設計に変えている
  • 購読やタイマーは必ずクリーンアップしている
  • カスタムフックの戻り値の形が使う側の意図を表している

この記事のゴール

  • 依存配列は 参照している値の宣言 であることが分かる
  • 副作用を useEffect に押し込めず UIの状態遷移として組み立てられる
  • カスタムフックで 再利用と責務分離 を安全に進められる

先に結論 よくある判断基準

useEffectはデータ同期の最後の手段

useEffectは 外部世界と同期する ときに使います。
外部世界の例

  • DOM API
  • WebSocketやEventSource
  • setIntervalなどのタイマー
  • 手動で購読するストア

逆に
描画結果を別の状態にコピーする ために使うと
二重の真実 が生まれてバグりやすいです。

依存配列は 参照した値の一覧 である

依存配列は

  • いつ再実行すべきか

を人間が推測する場所ではなく

  • このeffectはどの値に依存しているか

を宣言する場所です。
だから

  • 参照している値は入れる
  • 入れたくないなら参照しない設計にする

が基本方針になります。

状態はUIを表す最小集合にする

状態が増えるほど組み合わせが爆発します。
まずは

  • loading
  • error
  • data

のような 画面の状態遷移 を表す形に寄せ
派生値は計算する useMemoなど で表現すると安定します。

useState:状態管理の基本

基本的な使い方

import { useState } from 'react';

function Counter() {
    // [現在の値, 更新関数] = useState(初期値)
    const [count, setCount] = useState(0);

    return (
        <div>
            <p>カウント: {count}</p>
            <button onClick={() => setCount(count + 1)}>増やす</button>
            <button onClick={() => setCount(count - 1)}>減らす</button>
            <button onClick={() => setCount(0)}>リセット</button>
        </div>
    );
}

関数型更新

前の状態に基づいて更新する場合は、関数型更新を使用します。

function Counter() {
    const [count, setCount] = useState(0);

    // NG: 連続呼び出しで意図しない結果になる可能性
    const incrementTwiceBad = () => {
        setCount(count + 1);
        setCount(count + 1); // countはまだ古い値
    };

    // OK: 前の状態を受け取って更新
    const incrementTwice = () => {
        setCount(prev => prev + 1);
        setCount(prev => prev + 1);
    };

    return (
        <div>
            <p>カウント: {count}</p>
            <button onClick={incrementTwice}>+2</button>
        </div>
    );
}

オブジェクトや配列の状態

interface User {
    name: string;
    email: string;
    age: number;
}

function UserForm() {
    const [user, setUser] = useState<User>({
        name: '',
        email: '',
        age: 0
    });

    // オブジェクトの一部を更新(スプレッド構文)
    const updateName = (name: string) => {
        setUser(prev => ({ ...prev, name }));
    };

    // 汎用的な更新関数
    const updateField = (field: keyof User, value: string | number) => {
        setUser(prev => ({ ...prev, [field]: value }));
    };

    return (
        <div>
            <input
                value={user.name}
                onChange={e => updateField('name', e.target.value)}
                placeholder="名前"
            />
            <input
                value={user.email}
                onChange={e => updateField('email', e.target.value)}
                placeholder="メール"
            />
        </div>
    );
}

function TodoList() {
    const [todos, setTodos] = useState<string[]>([]);

    // 配列に追加
    const addTodo = (todo: string) => {
        setTodos(prev => [...prev, todo]);
    };

    // 配列から削除
    const removeTodo = (index: number) => {
        setTodos(prev => prev.filter((_, i) => i !== index));
    };

    // 配列の要素を更新
    const updateTodo = (index: number, newValue: string) => {
        setTodos(prev => prev.map((todo, i) => 
            i === index ? newValue : todo
        ));
    };

    return (
        <ul>
            {todos.map((todo, index) => (
                <li key={index}>
                    {todo}
                    <button onClick={() => removeTodo(index)}>削除</button>
                </li>
            ))}
        </ul>
    );
}

遅延初期化

初期値の計算がコストの高い場合は、関数を渡して遅延初期化します。

function ExpensiveComponent() {
    // NG: 毎回レンダリング時に実行される
    const [data, setData] = useState(expensiveComputation());

    // OK: 初回のみ実行される
    const [data, setData] = useState(() => expensiveComputation());

    return <div>{data}</div>;
}

// ローカルストレージからの読み込み
function usePersistentState<T>(key: string, defaultValue: T) {
    const [state, setState] = useState<T>(() => {
        const saved = localStorage.getItem(key);
        return saved ? JSON.parse(saved) : defaultValue;
    });

    useEffect(() => {
        localStorage.setItem(key, JSON.stringify(state));
    }, [key, state]);

    return [state, setState] as const;
}

useEffect:副作用の処理

先に結論:useEffectで解くべき問題/解かない問題

useEffectは「レンダーの結果」と「外部世界」を同期するための仕組みです。

使う(外部世界との同期)

  • データ取得(ただしキャッシュや状態管理レイヤがあるならそこで吸収)
  • 購読(WebSocket, EventSource, ブラウザイベント、ストア)
  • タイマー(setInterval / setTimeout)
  • DOM API(スクロール位置、フォーカスなど)

使わない(UI内の派生を作るため)

  • A から計算できる B を effect で setB する(状態の二重管理)
  • 「一度だけ実行したいから []」の乱用(本当は参照している値がある)

依存配列で詰まったときの実務手順

  1. まず「effect内で参照している値」を列挙する(props/state/関数)
  2. 依存配列に入れる(宣言)
  3. 入れたくない値がある場合は、参照しない設計に変える
    • 引数として渡す/責務を外へ出す(カスタムフック、呼び出し元へ持ち上げ)
    • useCallback / useMemo で安定化する(必要な場合のみ)

「依存配列から抜きたくなる」典型パターンの直し方

パターン1: “最新の値”だけ欲しい(stale closure回避)

イベントハンドラやタイマーで「最新のstateを読みたい」だけなら、stateをdepsに入れてeffectを作り直す必要がない場合があります。

function useLatest<T>(value: T) {
    const ref = useRef(value);
    useEffect(() => {
        ref.current = value;
    }, [value]);
    return ref;
}

function Example() {
    const [count, setCount] = useState(0);
    const countRef = useLatest(count);

    useEffect(() => {
        const id = window.setInterval(() => {
            // ここでは最新のcountを参照できる
            console.log(countRef.current);
        }, 1000);
        return () => clearInterval(id);
    }, [countRef]); // ref自体は安定なのでeffectは作り直されにくい

    return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

注意: これは「再購読が不要なケース」に限定して使います。外部世界(購読対象)を count に応じて変える必要があるなら、素直にdepsに入れて作り直します。

パターン2: effectの中で関数を参照してdepsが増える

effectの中で“処理”を組み立てているとdepsが雪だるま式に増えます。処理を外に出して引数化すると、依存関係が整理されます。

function fetchUserById(userId: string) {
    return fetch(`/api/users/${userId}`).then(r => r.json());
}

useEffect(() => {
    let cancelled = false;
    fetchUserById(userId).then(data => {
        if (cancelled) return;
        setUser(data);
    });
    return () => {
        cancelled = true;
    };
}, [userId]);

パターン3: オブジェクト/関数propsが毎回変わってループ

  • 必要なプリミティブだけ依存にする(例: config.id
  • どうしてもオブジェクトが必要なら、生成箇所を上に寄せる or useMemo で安定化する(ただし計測して必要な場合だけ)

パターン4: イベント購読でdepsが増え、毎回再購読してしまう

addEventListener のような購読は、depsが増えるほど「解除→再登録」が頻発しがちです。
購読自体は固定し、最新のコールバックだけ差し替える形(useEvent風)にすると安定します。

function useEvent<T extends (...args: any[]) => any>(handler: T) {
    const handlerRef = useRef(handler);
    useEffect(() => {
        handlerRef.current = handler;
    }, [handler]);

    return useCallback((...args: Parameters<T>) => {
        return handlerRef.current(...args);
    }, []);
}

function useWindowEvent(type: string, handler: (e: Event) => void) {
    const stableHandler = useEvent(handler);

    useEffect(() => {
        window.addEventListener(type, stableHandler);
        return () => window.removeEventListener(type, stableHandler);
    }, [type, stableHandler]);
}

function Example() {
    const [count, setCount] = useState(0);

    useWindowEvent('click', () => {
        // countが変わっても購読し直さず、常に最新のロジックが走る
        setCount(c => c + 1);
    });

    return <div>clicked: {count}</div>;
}

注意: これは「購読先(typeや対象)が変わらない」ケースで特に有効です。
購読対象自体が変わるなら、depsに入れて再購読するのが正しい設計です。

基本的な使い方

import { useState, useEffect } from 'react';

function UserProfile({ userId }: { userId: string }) {
    const [user, setUser] = useState<User | null>(null);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        // 副作用を実行
        async function fetchUser() {
            setLoading(true);
            try {
                const response = await fetch(`/api/users/${userId}`);
                const data = await response.json();
                setUser(data);
            } catch (error) {
                console.error('Failed to fetch user:', error);
            } finally {
                setLoading(false);
            }
        }

        fetchUser();
    }, [userId]); // userIdが変更されたときに再実行

    if (loading) return <div>Loading...</div>;
    if (!user) return <div>User not found</div>;

    return <div>{user.name}</div>;
}

依存配列の理解

useEffect(() => {
    // 毎回のレンダリング後に実行
});

useEffect(() => {
    // マウント時のみ実行
}, []);

useEffect(() => {
    // count または name が変更されたときに実行
}, [count, name]);

クリーンアップ関数

function Timer() {
    const [seconds, setSeconds] = useState(0);

    useEffect(() => {
        const intervalId = setInterval(() => {
            setSeconds(prev => prev + 1);
        }, 1000);

        // クリーンアップ関数
        return () => {
            clearInterval(intervalId);
        };
    }, []);

    return <div>経過時間: {seconds}</div>;
}

function ChatRoom({ roomId }: { roomId: string }) {
    useEffect(() => {
        const connection = createConnection(roomId);
        connection.connect();


### 事故りやすいポイント

- クリーンアップがない購読はStrictModeや画面遷移で二重購読二重送信メモリリークになりがち
- `setInterval` / イベントリスナー / WebSocket 必ず解除が基本
- 非同期処理fetch等完了後にsetStateする前にコンポーネントがアンマウントされ得る
        return () => {
            connection.disconnect();
        };
    }, [roomId]);

    return <div>チャットルーム: {roomId}</div>;
}

よくある間違いと対処法

// NG: 無限ループ
function BadComponent() {
    const [data, setData] = useState([]);

    useEffect(() => {
        fetch('/api/data')
            .then(res => res.json())
            .then(setData);
    }); // 依存配列がないため毎回実行

    return <div>{data.length}</div>;
}

// NG: オブジェクトを依存配列に含める
function AlsoBadComponent({ config }: { config: { id: string } }) {
    useEffect(() => {
        // configは毎回新しいオブジェクトなので無限ループ
    }, [config]);
}

// OK: 必要なプロパティだけを依存配列に含める
function GoodComponent({ config }: { config: { id: string } }) {
    const { id } = config;

    useEffect(() => {
        fetchData(id);
    }, [id]);
}

// OK: useMemoでオブジェクトをメモ化
function AlsoGoodComponent({ id, name }: { id: string; name: string }) {
    const config = useMemo(() => ({ id, name }), [id, name]);

    useEffect(() => {
        processConfig(config);
    }, [config]);
}

## useEffectの最小アンチパターン集実務でよく事故る順

### 1) effectに `async` を直接渡す

`useEffect` の戻り値は cleanup 関数または `undefined`である必要があります`async` 関数は Promise を返すので意図しない挙動になります

```tsx
// NG
useEffect(async () => {
    const res = await fetch('/api');
    // ...
}, []);

// OK
useEffect(() => {
    (async () => {
        const res = await fetch('/api');
        // ...
    })();
}, []);

2) cleanup漏れ(購読・タイマー・イベントで増殖)

// NG: 解除がない
useEffect(() => {
    const id = window.setInterval(tick, 1000);
}, []);

// OK
useEffect(() => {
    const id = window.setInterval(tick, 1000);
    return () => clearInterval(id);
}, [tick]);

3) 依存配列を“抜いて”eslintを黙らせる(依存隠し)

依存を抜くのは「再実行したくない」気持ちの表れですが、たいていは設計の匂いです。
抜くと、すぐに stale closure(古い値を参照)や更新漏れが起きます。

// NG
useEffect(() => {
    doSomething(userId);
    // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

// OK: 参照を宣言
useEffect(() => {
    doSomething(userId);
}, [userId]);

4) 「派生state」をeffectで作る(二重の真実)

このパターンは記事冒頭の「最小アンチパターン集(派生state)」でも扱っています。
ポイントは 「状態を増やす前に、計算で済むか」を疑う ことです。

5) fetchの競合(古いレスポンスで上書きする)

特に「検索」「入力に応じた候補」系で起きがちです。

// OK例: requestIdで最後のリクエストだけ反映(またはAbortController)
useEffect(() => {
    let active = true;
    (async () => {
        const res = await fetch(`/api?q=${q}`);
        const data = await res.json();
        if (!active) return;
        setResult(data);
    })();
    return () => {
        active = false;
    };
}, [q]);

6) unmount後に setState して警告・リークに見える

画面遷移が多いUIや、モーダル/タブの表示切替で発生します。
中断(AbortController)か「無視するフラグ」で防止します。

useEffect(() => {
    let cancelled = false;

    (async () => {
        const data = await load();
        if (cancelled) return;
        setState(data);
    })();

    return () => {
        cancelled = true;
    };
}, [load]);

7) StrictMode(開発時)の二重実行で壊れる副作用

  • cleanupがない購読
  • “一度だけ”前提の外部API呼び出し(トラッキング等)

対策は「二重でも壊れない」実装(idempotent)に寄せることです。
たとえば購読は必ず解除し、fetchは中断可能にしておきます。

8) 依存が増えて「解除→再登録」が頻発する(再購読嵐)

依存配列に状態やコールバックが増えるほど、購読が作り直されます。
購読自体は固定し、最新ロジックだけ差し替える(useEvent風)パターンが効くことがあります(※購読対象が固定のとき)。

function useEvent<T extends (...args: any[]) => any>(handler: T) {
    const ref = useRef(handler);
    useEffect(() => {
        ref.current = handler;
    }, [handler]);
    return useCallback((...args: Parameters<T>) => ref.current(...args), []);
}

useEffect(() => {
    const onMessage = useEvent((msg: MessageEvent) => {
        // 最新stateを使った処理
        handle(msg.data);
    });

    socket.addEventListener('message', onMessage);
    return () => socket.removeEventListener('message', onMessage);
}, [socket]);

9) effectの中で“重い処理”をして描画がカクつく

effectはレンダー後に走るため「UIは描画されたけど、その直後の処理で固まる」形の体感劣化になります。

  • 重い計算は useMemo や前処理へ寄せられないか
  • 分割できるならIdle時間やWeb Workerへ(ケースによる)
  • まずは計測(Performance/Profiler)して、本当にそこがボトルネックか確認

10) 1つのuseEffectに責務を詰め込みすぎる(巨大effect)

巨大effectは、依存配列が雪だるまになり、どの変更で何が起きるかが追いにくくなります。
分割の目安は次の通りです。

  • 同期したい「外部世界」が別なら effect を分ける(例: fetch と windowイベント)
  • 依存が違うなら effect を分ける(片方が変わると片方も再実行されるのを避ける)

結果として、cleanupの責務も分かれ、StrictMode耐性も上がります。

fetchの典型テンプレ(AbortControllerで中断できるようにする)

React 18のStrictMode(開発時)では、effectが マウント→アンマウント→再マウント のように二度走ることがあります。
副作用が二重でも壊れないように、fetchは中断可能にしておくと安全です。

useEffect(() => {
    const controller = new AbortController();

    (async () => {
        try {
            const res = await fetch(`/api/users/${userId}`, {
                signal: controller.signal,
            });
            const data = await res.json();
            setUser(data);
        } catch (e) {
            // AbortErrorは「中断しただけ」なのでエラー扱いしない
            if (e instanceof DOMException && e.name === 'AbortError') return;
            console.error(e);
        }
    })();

    return () => {
        controller.abort();
    };
}, [userId]);

実務テンプレ:データ取得をuseEffectで“事故らせない”

データ取得を自前で書くと、実務では次の4点で壊れがちです。

  1. レース: 古いリクエストが後から返り、最新データを上書きする
  2. 中断: 画面遷移やStrictModeの再マウントで不要なリクエストが残る
  3. エラー分類: Abortをエラーとして扱ってしまい、不要なエラー表示やリトライになる
  4. 再試行/キャッシュ: 要件が増えて、自作が雪だるま式に複雑化する

最小の“レース防止 + 中断 + エラー分類”テンプレ

type LoadState<T> =
    | { status: 'idle' }
    | { status: 'loading' }
    | { status: 'success'; data: T }
    | { status: 'error'; error: Error };

function isAbortError(e: unknown) {
    return e instanceof DOMException && e.name === 'AbortError';
}

function useUser(userId: string) {
    const [state, setState] = useState<LoadState<User>>({ status: 'idle' });

    useEffect(() => {
        const controller = new AbortController();
        let requestId = 0;
        requestId++;
        const current = requestId;

        setState({ status: 'loading' });

        (async () => {
            try {
                const res = await fetch(`/api/users/${userId}`, {
                    signal: controller.signal,
                });
                if (!res.ok) throw new Error(`HTTP ${res.status}`);
                const data = (await res.json()) as User;
                // レース防止: “最後に開始したリクエスト”だけ反映
                if (current !== requestId) return;
                setState({ status: 'success', data });
            } catch (e) {
                if (isAbortError(e)) return;
                setState({ status: 'error', error: e instanceof Error ? e : new Error('Unknown error') });
            }
        })();

        return () => controller.abort();
    }, [userId]);

    return state;
}

手に負えなくなる前に:ライブラリ導入の目安

次が必要になったら、SWR / TanStack Query(React Query)などの採用を検討した方が総コストが下がりやすいです。

  • キャッシュ(stale-while-revalidate、期限、キー設計)
  • リトライ(指数バックオフ、エラー種別で分岐)
  • 重複排除(同じリクエストの同時発行をまとめる)
  • ページネーション/無限スクロール
  • バックグラウンド更新

# useContext:コンテキストの活用

## 基本的な使い方

```tsx
import { createContext, useContext, useState, ReactNode } from 'react';

// コンテキストの型定義
interface ThemeContextType {
    theme: 'light' | 'dark';
    toggleTheme: () => void;
}

// コンテキストの作成
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

// プロバイダーコンポーネント
function ThemeProvider({ children }: { children: ReactNode }) {
    const [theme, setTheme] = useState<'light' | 'dark'>('light');

    const toggleTheme = () => {
        setTheme(prev => prev === 'light' ? 'dark' : 'light');
    };

    return (
        <ThemeContext.Provider value={{ theme, toggleTheme }}>
            {children}
        </ThemeContext.Provider>
    );
}

// カスタムフック(推奨)
function useTheme() {
    const context = useContext(ThemeContext);
    if (context === undefined) {
        throw new Error('useTheme must be used within a ThemeProvider');
    }
    return context;
}

// 使用例
function ThemeToggleButton() {
    const { theme, toggleTheme } = useTheme();

    return (
        <button onClick={toggleTheme}>
            現在のテーマ: {theme}
        </button>
    );
}

function App() {
    return (
        <ThemeProvider>
            <ThemeToggleButton />
        </ThemeProvider>
    );
}

複数のコンテキストを組み合わせる

interface AuthContextType {
    user: User | null;
    login: (email: string, password: string) => Promise<void>;
    logout: () => void;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

function AuthProvider({ children }: { children: ReactNode }) {
    const [user, setUser] = useState<User | null>(null);

    const login = async (email: string, password: string) => {
        const response = await fetch('/api/login', {
            method: 'POST',
            body: JSON.stringify({ email, password })
        });
        const userData = await response.json();
        setUser(userData);
    };

    const logout = () => {
        setUser(null);
    };

    return (
        <AuthContext.Provider value={{ user, login, logout }}>
            {children}
        </AuthContext.Provider>
    );
}

function useAuth() {
    const context = useContext(AuthContext);
    if (context === undefined) {
        throw new Error('useAuth must be used within an AuthProvider');
    }
    return context;
}

// 複数のプロバイダーを組み合わせる
function AppProviders({ children }: { children: ReactNode }) {
    return (
        <AuthProvider>
            <ThemeProvider>
                {children}
            </ThemeProvider>
        </AuthProvider>
    );
}

useReducer:複雑な状態管理

いつuseReducerを選ぶか(判断基準)

useReducer は「状態遷移が増えて、setState が散らばり始めた」ときに効きます。

  • 画面状態が loading / error / data のように遷移する
  • 複数の操作が同じstateの複数フィールドを更新する
  • 更新ルールを1箇所(reducer)に集めたい

逆に、単純な入力フォーム程度なら useState の方が読みやすいことも多いです。ポイントは“複雑さが増えたときに、遷移の中心を作る”ことです。

実務テンプレ:ロード状態(loading/error/success/empty)を状態遷移で表す

「とりあえず loading: booleanerror: string | null」で始めると、画面が増えた瞬間に破綻しやすいです。
useReducer で状態遷移を固定すると、更新漏れが減り、UIも読みやすくなります。

type State<T> =
    | { status: 'idle' }
    | { status: 'loading' }
    | { status: 'success'; data: T }
    | { status: 'empty' }
    | { status: 'error'; error: string };

type Action<T> =
    | { type: 'FETCH_START' }
    | { type: 'FETCH_SUCCESS'; data: T }
    | { type: 'FETCH_EMPTY' }
    | { type: 'FETCH_ERROR'; message: string };

function reducer<T>(state: State<T>, action: Action<T>): State<T> {
    switch (action.type) {
        case 'FETCH_START':
            return { status: 'loading' };
        case 'FETCH_SUCCESS':
            return { status: 'success', data: action.data };
        case 'FETCH_EMPTY':
            return { status: 'empty' };
        case 'FETCH_ERROR':
            return { status: 'error', error: action.message };
        default:
            return state;
    }
}

function UserList() {
    const [state, dispatch] = useReducer(reducer<User[]>, { status: 'idle' });

    useEffect(() => {
        const controller = new AbortController();
        dispatch({ type: 'FETCH_START' });

        (async () => {
            try {
                const res = await fetch('/api/users', { signal: controller.signal });
                if (!res.ok) throw new Error(String(res.status));
                const users = (await res.json()) as User[];
                if (users.length === 0) {
                    dispatch({ type: 'FETCH_EMPTY' });
                } else {
                    dispatch({ type: 'FETCH_SUCCESS', data: users });
                }
            } catch (e) {
                if (e instanceof DOMException && e.name === 'AbortError') return;
                dispatch({ type: 'FETCH_ERROR', message: e instanceof Error ? e.message : 'Unknown error' });
            }
        })();

        return () => controller.abort();
    }, []);

    if (state.status === 'idle' || state.status === 'loading') return <div>Loading...</div>;
    if (state.status === 'error') return <div>Error: {state.error}</div>;
    if (state.status === 'empty') return <div>No users</div>;

    return (
        <ul>
            {state.data.map(u => (
                <li key={u.id}>{u.name}</li>
            ))}
        </ul>
    );
}

基本的な使い方

import { useReducer } from 'react';

interface State {
    count: number;
    step: number;
}

type Action =
    | { type: 'increment' }
    | { type: 'decrement' }
    | { type: 'reset' }
    | { type: 'setStep'; payload: number };

function reducer(state: State, action: Action): State {
    switch (action.type) {
        case 'increment':
            return { ...state, count: state.count + state.step };
        case 'decrement':
            return { ...state, count: state.count - state.step };
        case 'reset':
            return { count: 0, step: 1 };
        case 'setStep':
            return { ...state, step: action.payload };
        default:
            return state;
    }
}

function Counter() {
    const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 });

    return (
        <div>
            <p>カウント: {state.count}</p>
            <p>ステップ: {state.step}</p>
            <button onClick={() => dispatch({ type: 'increment' })}>+</button>
            <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
            <button onClick={() => dispatch({ type: 'reset' })}>リセット</button>
            <input
                type="number"
                value={state.step}
                onChange={e => dispatch({ type: 'setStep', payload: Number(e.target.value) })}
            />
        </div>
    );
}

フォーム管理での活用

interface FormState {
    values: {
        name: string;
        email: string;
        password: string;
    };
    errors: {
        name?: string;
        email?: string;
        password?: string;
    };
    isSubmitting: boolean;
}

type FormAction =
    | { type: 'SET_FIELD'; field: string; value: string }
    | { type: 'SET_ERROR'; field: string; error: string }
    | { type: 'CLEAR_ERRORS' }
    | { type: 'SUBMIT_START' }
    | { type: 'SUBMIT_END' }
    | { type: 'RESET' };

const initialState: FormState = {
    values: { name: '', email: '', password: '' },
    errors: {},
    isSubmitting: false
};

function formReducer(state: FormState, action: FormAction): FormState {
    switch (action.type) {
        case 'SET_FIELD':
            return {
                ...state,
                values: { ...state.values, [action.field]: action.value },
                errors: { ...state.errors, [action.field]: undefined }
            };
        case 'SET_ERROR':
            return {
                ...state,
                errors: { ...state.errors, [action.field]: action.error }
            };
        case 'CLEAR_ERRORS':
            return { ...state, errors: {} };
        case 'SUBMIT_START':
            return { ...state, isSubmitting: true };
        case 'SUBMIT_END':
            return { ...state, isSubmitting: false };
        case 'RESET':
            return initialState;
        default:
            return state;
    }
}

function SignupForm() {
    const [state, dispatch] = useReducer(formReducer, initialState);

    const handleChange = (field: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
        dispatch({ type: 'SET_FIELD', field, value: e.target.value });
    };

    const handleSubmit = async (e: React.FormEvent) => {
        e.preventDefault();
        dispatch({ type: 'SUBMIT_START' });

        try {
            await submitForm(state.values);
            dispatch({ type: 'RESET' });
        } catch (error) {
            // エラー処理
        } finally {
            dispatch({ type: 'SUBMIT_END' });
        }
    };

    return (
        <form onSubmit={handleSubmit}>
            <input
                value={state.values.name}
                onChange={handleChange('name')}
                placeholder="名前"
            />
            {state.errors.name && <span>{state.errors.name}</span>}
            
            <input
                value={state.values.email}
                onChange={handleChange('email')}
                placeholder="メール"
            />
            {state.errors.email && <span>{state.errors.email}</span>}
            
            <button type="submit" disabled={state.isSubmitting}>
                {state.isSubmitting ? '送信中...' : '登録'}
            </button>
        </form>
    );
}

useMemo と useCallback:パフォーマンス最適化

先に結論:まず計測、次に構造、最後にuseMemo/useCallback

useMemo/useCallback は便利ですが、入れれば速いではありません。
まずは「何が再レンダーを引き起こしているか」を把握します。

  • 親のstate更新で子が再レンダーしているだけなら正常(Reactはそれを前提に最適化されています)
  • 高コストなのは「重い計算」「巨大リスト」「メモ化された子に毎回新しいprops(関数/オブジェクト)を渡す」など

実務では React DevTools Profiler で“どこが重いか”を見てから、必要最低限の箇所だけ最適化するのが安全です。

最適化の実務手順(迷ったらこれ)

  1. Profilerで計測: どのコンポーネントが何回レンダーされ、どれが重いかを確認
  2. 原因を分類: 親のstate変更/props同一性(関数・オブジェクト)/重い計算/巨大リスト
  3. 構造を直す: stateのスコープを狭める、propsを小さくする、リストを分割/仮想化
  4. 最後にメモ化: それでも必要な箇所だけ React.memo + useMemo/useCallback

この順番にすると「メモ化の沼」にハマりにくいです。

useMemo/useCallbackの最小アンチパターン集(NG/OKで覚える)

1) “とりあえず”で入れる(効果がない、複雑さだけ増える)

// NG: 軽い計算にuseMemo
const doubled = useMemo(() => count * 2, [count]);

// OK
const doubled2 = count * 2;

目安として、次のようなときに初めて候補になります。

  • 計算が重い(配列の集計/ソート/正規化など)
  • その計算が「同じ入力で何度も走っている」
  • もしくは memo化された子に渡すpropsの同一性がボトルネック

2) depsを抜いて“固定化”する(stale closureになる)

// NG: idを参照しているのにdepsが空
const onClick = useCallback(() => doSomething(id), []);

// OK
const onClick2 = useCallback(() => doSomething(id), [id]);

3) useCallbackしても子がmemo化されていない(無意味になりがち)

// NG: Childがmemo化されていないなら、親のuseCallbackは効きにくい
const onClick = useCallback(() => setCount(c => c + 1), []);
return <Child onClick={onClick} />;

// OK: 本当にprops同一性が必要な場所だけ、子側も含めて設計する
const ChildMemo = memo(Child);
return <ChildMemo onClick={onClick} />;

4) useMemoで「参照安定のためだけ」に巨大オブジェクトを作る

オブジェクトを安定化したい気持ちで useMemo(() => ({...巨大...}), deps) を書くと、
deps設計が難しく、更新漏れや“結局毎回作られる”が起きがちです。

まずは「子に渡すpropsの形」を小さくする(必要なプリミティブだけ渡す)方が安全です。

5) memo化の順序ミス(まず構造、最後にメモ化)

症状: 遅い → useMemo/useCallbackを追加 → まだ遅い → さらに追加

このループは大抵、原因が「巨大リスト」「無駄な再レンダー」「propsの形が悪い」「分割が足りない」などにあります。
メモ化の前に、次を優先すると改善が出やすいです。

  • コンポーネント分割(状態のスコープを小さくする)
  • propsを小さくする(大きいオブジェクトを渡さない)
  • リストは仮想化(例: react-window)を検討

useMemo:計算結果のメモ化

import { useMemo, useState } from 'react';

function ExpensiveComponent({ items }: { items: number[] }) {
    const [filter, setFilter] = useState('');

    // フィルタリング結果をメモ化
    const filteredItems = useMemo(() => {
        console.log('Filtering items...');
        return items.filter(item => item.toString().includes(filter));
    }, [items, filter]);

    // 合計値をメモ化
    const total = useMemo(() => {
        console.log('Calculating total...');
        return filteredItems.reduce((sum, item) => sum + item, 0);
    }, [filteredItems]);

    return (
        <div>
            <input value={filter} onChange={e => setFilter(e.target.value)} />
            <p>合計: {total}</p>
            <ul>
                {filteredItems.map(item => <li key={item}>{item}</li>)}
            </ul>
        </div>
    );
}

### 使いどころの目安

- 計算が重いかつ入力が変わらないレンダーが多いときに効きます
- 逆に依存が毎回変わるなら効果は薄くメモ化のオーバーヘッドが勝つこともあります

**注意**: `useMemo` 再計算しない保証ではなくあくまで最適化ヒントです結果が捨てられる可能性があります)。ロジックの正しさを `useMemo` に依存させないようにします

useCallback:関数のメモ化

import { useCallback, useState, memo } from 'react';

// メモ化されたコンポーネント
const ExpensiveButton = memo(({ onClick, label }: { onClick: () => void; label: string }) => {
    console.log(`Rendering button: ${label}`);
    return <button onClick={onClick}>{label}</button>;
});

function Parent() {
    const [count, setCount] = useState(0);
    const [text, setText] = useState('');

    // useCallbackでメモ化しないと、textが変わるたびにbuttonが再レンダリング
    const increment = useCallback(() => {
        setCount(prev => prev + 1);
    }, []);

    const decrement = useCallback(() => {
        setCount(prev => prev - 1);
    }, []);

    return (
        <div>
            <input value={text} onChange={e => setText(e.target.value)} />
            <p>Count: {count}</p>
            <ExpensiveButton onClick={increment} label="増やす" />
            <ExpensiveButton onClick={decrement} label="減らす" />
        </div>
    );
}

### 使いどころの目安

- `React.memo` された子に関数propsを渡していてpropsの同一性が重要なとき
- `useEffect` の依存として関数を渡すときただしそもそも関数を依存にする設計を疑うのが先

**注意**: `useCallback(fn, deps)` 関数を固定するのではなくdepsが変わるまで同じ関数参照を返すですdepsを抜くのではなく参照している値を正しくdepsに入れます

使いすぎに注意

// NG: 単純な計算にuseMemoは不要
const doubled = useMemo(() => count * 2, [count]);
// OK: 単純に計算すればいい
const doubled = count * 2;

// NG: 単純なコールバックにuseCallbackは不要(子がmemo化されていない場合)
const handleClick = useCallback(() => setCount(count + 1), [count]);
// OK: そのまま書く
const handleClick = () => setCount(count + 1);

useRef:参照の保持

useRefは2種類ある(混同しない)

useRef は同じAPIですが、実務では用途が2つに分かれます。

  1. DOM参照ref={...}): inputのfocus、サイズ計測など
  2. 値の箱(レンダー間で保持したいが再レンダーは不要): intervalId、最新のcallback、前回値など

後者をstateで持つと「更新のたびに再レンダー」が走り、タイマーやイベント処理が不安定になりやすいです。

useRefの最小アンチパターン集(NG/OKで覚える)

1) refでUIを更新しようとする

refの更新は再レンダーを起こしません。UIに出す値はstateで管理します。

// NG
const countRef = useRef(0);
const inc = () => {
    countRef.current += 1;
};
return (
    <div>
        <button onClick={inc}>+1</button>
        <p>{countRef.current}</p>
    </div>
);

// OK
const [count, setCount] = useState(0);
return (
    <div>
        <button onClick={() => setCount(c => c + 1)}>+1</button>
        <p>{count}</p>
    </div>
);

2) refを “mutable state” として濫用する(デバッグ不能になりがち)

refは便利ですが、何でもrefに入れると「いつ、どこで、何が変わったか」が追えなくなります。
基本は UIに影響するならstate外部オブジェクトのハンドル(timerId等)ならref で割り切ると安定します。

3) 依存配列を減らすためにrefに逃がす(本当は再同期が必要)

「depsが増えるのが嫌だからrefに入れておく」は危険です。
外部世界(購読対象や接続先)が変わるなら、再購読/再接続が必要で、depsに入れるのが正しい設計です。

4) 最新値保持パターンの落とし穴

最新値をrefで保持する(useLatest / useEvent)は便利ですが、使いどころを間違えるとバグります。

  • OK: タイマー/イベント内で“最新の値を読む”だけ(購読対象は固定)
  • NG: 本来は「値が変わったら外部世界を切り替えるべき」なのに、refで誤魔化す

DOM要素への参照

import { useRef, useEffect } from 'react';

function TextInput() {
    const inputRef = useRef<HTMLInputElement>(null);

    useEffect(() => {
        // マウント時にフォーカス
        inputRef.current?.focus();
    }, []);

    const handleClick = () => {
        inputRef.current?.select();
    };

    return (
        <div>
            <input ref={inputRef} />
            <button onClick={handleClick}>全選択</button>
        </div>
    );
}

値の保持(再レンダリングを引き起こさない)

function Timer() {
    const [seconds, setSeconds] = useState(0);
    const intervalRef = useRef<number | null>(null);

    const start = () => {
        if (intervalRef.current !== null) return;
        
        intervalRef.current = window.setInterval(() => {
            setSeconds(prev => prev + 1);
        }, 1000);
    };

    const stop = () => {
        if (intervalRef.current !== null) {
            clearInterval(intervalRef.current);
            intervalRef.current = null;
        }
    };

    useEffect(() => {
        return () => stop(); // クリーンアップ
    }, []);

    return (
        <div>
            <p>{seconds}</p>
            <button onClick={start}>開始</button>
            <button onClick={stop}>停止</button>
        </div>
    );
}

// 前回の値を保持
function usePrevious<T>(value: T): T | undefined {
    const ref = useRef<T>();
    
    useEffect(() => {
        ref.current = value;
    });
    
    return ref.current;
}

function Counter() {
    const [count, setCount] = useState(0);
    const prevCount = usePrevious(count);

    return (
        <div>
            <p>現在: {count}, 前回: {prevCount}</p>
            <button onClick={() => setCount(count + 1)}>+1</button>
        </div>
    );
}

カスタムフックの作成

カスタムフック設計のミニテンプレ(実務)

カスタムフックは「再利用」のためだけではなく、責務分離のために使うと効果が出ます。

  • 入力(引数): 外から注入できるものを増やすとテストしやすい(例: fetcher を渡す)
  • 出力(戻り値): UIが欲しい形(状態遷移)で返す({data, loading, error, actions...}
  • 失敗時: 例外を握りつぶさず、呼び出し側が表示できる形にする
  • ブラウザAPI: SSR環境では存在しない(window)ので分岐や遅延初期化を意識する

テスト容易性の観点(この4つで差がつく)

  • 依存注入: fetchlocalStorage を直呼びしない。fetcher / storage を引数で受け取れるようにする
  • 副作用境界: effectは“外部世界との同期”だけにし、ドメインロジックは純粋関数へ寄せる
  • 時間: debounce/intervalなど時間依存は、delayを引数にする・テストでは擬似時計を使える設計にする
  • 中断: abort/cancelをサポートすると、StrictModeや画面遷移でも壊れにくく、テストもしやすい

小さなテンプレ例:fetcher注入 + Abort対応のuseAsync

type AsyncState<T> =
    | { status: 'idle' }
    | { status: 'loading' }
    | { status: 'success'; data: T }
    | { status: 'error'; error: Error };

type Fetcher<T> = (signal: AbortSignal) => Promise<T>;

function useAsync<T>(fetcher: Fetcher<T>, deps: unknown[]) {
    const [state, setState] = useState<AsyncState<T>>({ status: 'idle' });

    useEffect(() => {
        const controller = new AbortController();
        setState({ status: 'loading' });

        fetcher(controller.signal)
            .then((data) => setState({ status: 'success', data }))
            .catch((e) => {
                if (e instanceof DOMException && e.name === 'AbortError') return;
                setState({ status: 'error', error: e instanceof Error ? e : new Error('Unknown error') });
            });

        return () => controller.abort();
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, deps);

    return state;
}

// usage: fetcherを注入できるのでテストが簡単
// const state = useAsync((signal) => api.getUser(userId, { signal }), [userId]);

注意: 上のテンプレは「depsの設計」が肝です。deps を外から渡す場合は、呼び出し側で依存が正しく宣言される前提になります。
もう一段安全にするなら、deps を引数で渡さず useAsync(fetcher, [/*参照した値*/]) の形に固定するなど、チームのルールを決めるのがおすすめです。

基本的なカスタムフック

// useToggle: トグル状態の管理
function useToggle(initialValue = false) {
    const [value, setValue] = useState(initialValue);

    const toggle = useCallback(() => {
        setValue(prev => !prev);
    }, []);

    const setTrue = useCallback(() => setValue(true), []);
    const setFalse = useCallback(() => setValue(false), []);

    return { value, toggle, setTrue, setFalse };
}

// 使用例
function Modal() {
    const { value: isOpen, toggle, setFalse: close } = useToggle();

    return (
        <div>
            <button onClick={toggle}>モーダルを開く</button>
            {isOpen && (
                <div className="modal">
                    <p>モーダルの内容</p>
                    <button onClick={close}>閉じる</button>
                </div>
            )}
        </div>
    );
}

データフェッチング用カスタムフック

interface UseFetchResult<T> {
    data: T | null;
    loading: boolean;
    error: Error | null;
    refetch: () => void;
}

function useFetch<T>(url: string): UseFetchResult<T> {
    const [data, setData] = useState<T | null>(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState<Error | null>(null);

    const fetchData = useCallback(async () => {
        setLoading(true);
        setError(null);

        try {
            const response = await fetch(url);
            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }
            const result = await response.json();
            setData(result);
        } catch (e) {
            setError(e instanceof Error ? e : new Error('Unknown error'));
        } finally {
            setLoading(false);
        }
    }, [url]);

    useEffect(() => {
        fetchData();
    }, [fetchData]);

    return { data, loading, error, refetch: fetchData };
}

### 事故りやすいポイントfetch系

- URLが変わるたびに古いリクエストが後から返って上書きするレースに注意中断もしくはリクエストIDで無視
- キャッシュ/リトライ/重複排除が必要になったらSWR/React Query等の採用も含めて検討する自作し続けると運用負荷が増えがち

// 使用例
function UserList() {
    const { data: users, loading, error, refetch } = useFetch<User[]>('/api/users');

    if (loading) return <div>Loading...</div>;
    if (error) return <div>Error: {error.message}</div>;

    return (
        <div>
            <button onClick={refetch}>更新</button>
            <ul>
                {users?.map(user => (
                    <li key={user.id}>{user.name}</li>
                ))}
            </ul>
        </div>
    );
}

フォーム管理用カスタムフック

interface UseFormOptions<T> {
    initialValues: T;
    validate?: (values: T) => Partial<Record<keyof T, string>>;
    onSubmit: (values: T) => Promise<void>;
}

function useForm<T extends Record<string, any>>({
    initialValues,
    validate,
    onSubmit
}: UseFormOptions<T>) {
    const [values, setValues] = useState<T>(initialValues);
    const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
    const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});
    const [isSubmitting, setIsSubmitting] = useState(false);

    const handleChange = (field: keyof T) => (
        e: React.ChangeEvent<HTMLInputElement>
    ) => {
        setValues(prev => ({ ...prev, [field]: e.target.value }));
    };

    const handleBlur = (field: keyof T) => () => {
        setTouched(prev => ({ ...prev, [field]: true }));
        
        if (validate) {
            const validationErrors = validate(values);
            setErrors(validationErrors);
        }
    };

    const handleSubmit = async (e: React.FormEvent) => {
        e.preventDefault();

        if (validate) {
            const validationErrors = validate(values);
            setErrors(validationErrors);

            if (Object.keys(validationErrors).length > 0) {
                return;
            }
        }

        setIsSubmitting(true);
        try {
            await onSubmit(values);
            setValues(initialValues);
            setTouched({});
        } finally {
            setIsSubmitting(false);
        }
    };

    const reset = () => {
        setValues(initialValues);
        setErrors({});
        setTouched({});
    };

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

// 使用例
function SignupForm() {
    const form = useForm({
        initialValues: { email: '', password: '' },
        validate: (values) => {
            const errors: Record<string, string> = {};
            if (!values.email) errors.email = 'メールは必須です';
            if (!values.password) errors.password = 'パスワードは必須です';
            if (values.password.length < 8) {
                errors.password = 'パスワードは8文字以上必要です';
            }
            return errors;
        },
        onSubmit: async (values) => {
            await fetch('/api/signup', {
                method: 'POST',
                body: JSON.stringify(values)
            });
        }
    });

    return (
        <form onSubmit={form.handleSubmit}>
            <input
                value={form.values.email}
                onChange={form.handleChange('email')}
                onBlur={form.handleBlur('email')}
                placeholder="メール"
            />
            {form.touched.email && form.errors.email && (
                <span>{form.errors.email}</span>
            )}

            <input
                type="password"
                value={form.values.password}
                onChange={form.handleChange('password')}
                onBlur={form.handleBlur('password')}
                placeholder="パスワード"
            />
            {form.touched.password && form.errors.password && (
                <span>{form.errors.password}</span>
            )}

            <button type="submit" disabled={form.isSubmitting}>
                登録
            </button>
        </form>
    );
}

ローカルストレージ連携

function useLocalStorage<T>(key: string, initialValue: T) {
    const [storedValue, setStoredValue] = useState<T>(() => {
        try {
            const item = window.localStorage.getItem(key);
            return item ? JSON.parse(item) : initialValue;
        } catch (error) {
            console.error(error);
            return initialValue;
        }
    });

    const setValue = (value: T | ((val: T) => T)) => {
        try {
            const valueToStore = value instanceof Function ? value(storedValue) : value;
            setStoredValue(valueToStore);
            window.localStorage.setItem(key, JSON.stringify(valueToStore));
        } catch (error) {
            console.error(error);
        }
    };

    return [storedValue, setValue] as const;
}

// 使用例
function Settings() {
    const [settings, setSettings] = useLocalStorage('settings', {
        darkMode: false,
        notifications: true
    });

    return (
        <div>
            <label>
                <input
                    type="checkbox"
                    checked={settings.darkMode}
                    onChange={e => setSettings({
                        ...settings,
                        darkMode: e.target.checked
                    })}
                />
                ダークモード
            </label>
        </div>
    );
}

デバウンス・スロットル

// useDebounce
function useDebounce<T>(value: T, delay: number): T {
    const [debouncedValue, setDebouncedValue] = useState<T>(value);

    useEffect(() => {
        const handler = setTimeout(() => {
            setDebouncedValue(value);
        }, delay);

        return () => {
            clearTimeout(handler);
        };
    }, [value, delay]);

    return debouncedValue;
}

// 使用例:検索入力
function SearchInput() {
    const [query, setQuery] = useState('');
    const debouncedQuery = useDebounce(query, 500);

    useEffect(() => {
        if (debouncedQuery) {
            // APIを呼び出す
            searchAPI(debouncedQuery);
        }
    }, [debouncedQuery]);

    return (
        <input
            value={query}
            onChange={e => setQuery(e.target.value)}
            placeholder="検索..."
        />
    );
}

// useDebouncedCallback
function useDebouncedCallback<T extends (...args: any[]) => any>(
    callback: T,
    delay: number
) {
    const callbackRef = useRef(callback);
    const timeoutRef = useRef<number>();

    useEffect(() => {
        callbackRef.current = callback;
    }, [callback]);

    return useCallback((...args: Parameters<T>) => {
        if (timeoutRef.current) {
            clearTimeout(timeoutRef.current);
        }

        timeoutRef.current = window.setTimeout(() => {
            callbackRef.current(...args);
        }, delay);
    }, [delay]);
}

まとめ

この記事では、React Hooksについて基礎から実践的なカスタムフックまで解説しました。

ここから先は「現場で壊れないための最小チェックリスト」です。レビューやデバッグでそのまま使えます。

Hook 用途
useState 状態管理の基本
useEffect 副作用の処理
useContext グローバルな状態共有
useReducer 複雑な状態管理
useMemo 計算結果のメモ化
useCallback 関数のメモ化
useRef DOM参照・値の保持

実務チェックリスト(レビュー観点)

state

  • stateはUIの状態遷移の最小集合になっている(派生値のコピーを持っていない)
  • 状態が増えてきたら useReducer で遷移の中心を作れている(更新ロジックが散らばっていない)

useEffect

  • effectは「外部世界との同期」だけに使っている(派生stateを作っていない)
  • 依存配列から値を抜いてeslintを黙らせていない(抜きたくなる設計を直している)
  • 購読/タイマー/イベントは必ずcleanupしている
  • fetch等の非同期は「中断か無視」を実装している(StrictMode/画面遷移で壊れない)

追加で、次を満たしていると“事故率”が下がります。

  • effectに async を直接渡していない(Promiseを返さない)
  • fetchの競合(古いレスポンスの上書き)を防げている(中断/リクエストID/無視フラグ)
  • StrictMode(開発時)で副作用が二重でも壊れない(idempotent)

参照: 本文の「useEffectの最小アンチパターン集」を見ながらレビューすると、見落としが減ります。

useMemo / useCallback

  • まず計測(Profiler)して、必要な箇所だけに入れている(惰性で入れていない)
  • メモ化が“正しさ”に影響していない(あくまで最適化)
  • 子が React.memo されていないのに useCallback を乱用していない

追加で、次を確認すると“最適化がバグの原因”になるのを避けやすいです。

  • depsを抜いて固定化していない(stale closureにならない)
  • 「オブジェクトを安定化するためだけのuseMemo」が巨大化していない(propsの形の見直しが先)
  • メモ化の前に、状態スコープ/分割/リスト仮想化など“構造改善”を検討している

useRef

  • useRef を「DOM参照」と「値の箱」で混同していない
  • refの更新でUIを変えようとしていない(UIに影響するならstate)

追加で、次を満たしていると設計が壊れにくいです。

  • depsを減らすためにrefへ逃がしていない(本来再同期が必要ならdepsに入れる)
  • refを“なんでも入るmutable state”にしてデバッグ不能にしていない

カスタムフック

  • 戻り値がUIの意図を表す形({data, loading, error, actions...})になっている
  • 依存性注入(fetcher等)やSSR配慮が必要な箇所を意識できている

追加で、次を確認するとテストと運用が楽になります。

  • 外部依存(fetch/storage/time)を注入できる(直呼びしない)
  • StrictMode/画面遷移を想定して中断(abort/cancel)できる

トラブルシュート最短ルート

「無限ループ」や「effectが止まらない」

  1. effectの中で setState していないか(派生stateを作っていないか)
  2. effectが参照している値を列挙し、依存配列に入っているか
  3. 依存に「毎回変わる object/function」が入っていないか
    • 必要な値だけ依存にする/作成場所を変える/必要な場合だけ useMemo/useCallback

「StrictModeで二重に走って壊れる(開発だけ壊れる)」

  • cleanupがあるか(購読解除、タイマー停止)
  • fetchが中断可能か(AbortController)
  • 副作用が二重でも壊れない(idempotent)設計か

「遅い/再レンダーが多い」

  1. React DevTools Profilerで“本当に重い場所”を特定する
  2. propsの形を見直す(巨大オブジェクト、毎回作る配列/関数)
  3. 最後に React.memo + useCallback/useMemo を最小限入れる

カスタムフックのポイント

  • useから始める命名規則
  • 再利用可能なロジックを抽出
  • 複数のHooksを組み合わせて高度な機能を実現
  • テストしやすい設計

Hooksを使いこなすことで、より読みやすく保守しやすいReactアプリケーションを構築できます。まずは基本的なHooksから始めて、徐々にカスタムフックの作成にも挑戦してみてください!

8
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?