T-CREATOR

React フック完全チートシート:useState から useTransition まで用途別早見表

React フック完全チートシート:useState から useTransition まで用途別早見表

React のコードを書いていて「このフックはどんな時に使うんだっけ?」と迷った経験はありませんか。状態管理、副作用、パフォーマンス最適化など、さまざまな場面で活躍する React フックですが、種類が多く使い分けに悩むこともあるでしょう。

本記事では、useState から useTransition まで、React で提供されているすべての主要フックを用途別に整理し、それぞれの使い方を実例とともに解説します。初心者の方でも理解しやすいよう、コードには丁寧なコメントを付け、段階的に説明していきますね。

React フック早見表

まずは全フックの概要を一覧で確認しましょう。この表を見れば、どのフックが何のために使われるのか一目でわかります。

#フック名カテゴリ主な用途使用頻度
1useState状態管理コンポーネント内の状態を管理★★★
2useEffect副作用データ取得、購読、DOM 操作などの副作用処理★★★
3useContext状態管理グローバルな状態をコンポーネント間で共有★★☆
4useReducer状態管理複雑な状態ロジックを管理★★☆
5useCallbackパフォーマンス関数のメモ化で不要な再レンダリングを防止★★☆
6useMemoパフォーマンス計算結果のメモ化で重い処理を最適化★★☆
7useRefDOM/値の保持DOM 要素への参照や値の永続的な保持★★☆
8useLayoutEffect副作用DOM 変更直後の同期的な副作用処理★☆☆
9useImperativeHandle高度な制御親コンポーネントに公開する ref の値をカスタマイズ★☆☆
10useDebugValue開発支援カスタムフックのデバッグ情報を表示★☆☆
11useDeferredValueパフォーマンス値の更新を遅延させて UI の応答性を向上★☆☆
12useTransitionパフォーマンス状態更新を低優先度として扱い UI をブロックしない★☆☆
13useIdユーティリティサーバー・クライアント間で一意な ID を生成★☆☆
14useSyncExternalStore外部ストア外部ストアの購読と同期★☆☆
15useInsertionEffectスタイリングCSS-in-JS ライブラリ向けのスタイル挿入★☆☆

背景

React 16.8 で登場したフックは、関数コンポーネントで状態管理や副作用処理を行えるようにする画期的な機能でした。それまではクラスコンポーネントでしか実現できなかった機能が、シンプルな関数で書けるようになったのです。

以下の図は、React フックが登場する前と後のコンポーネント設計の違いを示しています。

mermaidflowchart TB
    before[クラスコンポーネント時代]
    after[フック時代(React 16.8以降)]

    before --> classNode[Class Component:this の理解が必要・ライフサイクルメソッド・コードが冗長]
    after --> funcNode[Function Component + Hooks:シンプルな関数・ロジックの再利用が容易・テストしやすい]

    classNode --|複雑化|--> pain[理解が難しい/コードが分散/再利用しにくい]
    funcNode --|改善|--> benefit[読みやすい/ロジックがまとまる/カスタムフックで再利用]

クラスコンポーネントでは、状態管理に this.state を使い、ライフサイクルメソッドで副作用を処理していました。しかし、関連するロジックが複数のメソッドに分散し、コードの理解や保守が難しくなるという課題があったのです。

フックの登場により、関連するロジックをまとめて記述でき、カスタムフックとして再利用することも可能になりました。現在では、useState や useEffect といった基本的なフックに加え、パフォーマンス最適化や並行レンダリングに対応した高度なフックも提供されています。

課題

フックは便利ですが、種類が増えるにつれて以下のような課題が生じています。

多くの開発者が直面する問題は、「どのフックをいつ使うべきか」の判断が難しいことです。useState と useReducer の使い分け、useCallback と useMemo の違い、useEffect と useLayoutEffect の選択など、似た機能を持つフックの区別がつきにくいのです。

また、React 18 で追加された useTransition や useDeferredValue といった新しいフックは、並行レンダリングという新しい概念を理解する必要があります。これらのフックを効果的に使うには、React の内部動作についての理解も求められるでしょう。

以下の図は、フック選択時の主な判断ポイントを示しています。

mermaidflowchart TD
    start["フックを選ぶ"]

    start --> q1{"何をしたい?"}

    q1 -->|"状態管理"| state["useState/useReducer<br/>useContext"]
    q1 -->|"副作用処理"| effect["useEffect<br/>useLayoutEffect"]
    q1 -->|"パフォーマンス最適化"| perf["useMemo/useCallback<br/>useTransition/useDeferredValue"]
    q1 -->|"DOM操作"| dom["useRef<br/>useImperativeHandle"]

    state --> q2{"複雑な状態?"}
    q2 -->|"シンプル"| useState_result["useState"]
    q2 -->|"複雑"| useReducer_result["useReducer"]
    q2 -->|"グローバル"| useContext_result["useContext"]

    effect --> q3{"実行タイミング?"}
    q3 -->|"非同期でOK"| useEffect_result["useEffect"]
    q3 -->|"DOM変更直後"| useLayoutEffect_result["useLayoutEffect"]

    perf --> q4{"何を最適化?"}
    q4 -->|"計算結果"| useMemo_result["useMemo"]
    q4 -->|"関数"| useCallback_result["useCallback"]
    q4 -->|"UI応答性"| transition_result["useTransition<br/>useDeferredValue"]

さらに、フックには「ルール」があり、これを守らないとエラーが発生します。条件分岐の中でフックを呼び出してはいけない、ループ内で使ってはいけないといった制約は、初心者にとって理解しにくい部分でもあるのです。

解決策

これらの課題を解決するために、本記事では各フックを用途別に分類し、具体的な使用例とともに解説します。以下のカテゴリに分けて、それぞれのフックの特徴と使い分けのポイントを明確にしましょう。

フックのカテゴリ分類

React フックは大きく 5 つのカテゴリに分けられます。

  1. 状態管理フック: useState、useReducer、useContext
  2. 副作用フック: useEffect、useLayoutEffect、useInsertionEffect
  3. パフォーマンス最適化フック: useMemo、useCallback、useTransition、useDeferredValue
  4. 参照・DOM 操作フック: useRef、useImperativeHandle
  5. ユーティリティフック: useId、useDebugValue、useSyncExternalStore

この分類により、目的に応じて適切なフックを素早く見つけられます。例えば、コンポーネント内で値を保持したいなら状態管理フック、データ取得や購読処理なら副作用フック、といった具合ですね。

以下の図は、フック選択のフローを視覚化したものです。

mermaidflowchart LR
    purpose["目的"]

    purpose --> cat1["状態管理"]
    purpose --> cat2["副作用処理"]
    purpose --> cat3["パフォーマンス"]
    purpose --> cat4["DOM/参照"]
    purpose --> cat5["その他"]

    cat1 --> h1["useState<br/>useReducer<br/>useContext"]
    cat2 --> h2["useEffect<br/>useLayoutEffect<br/>useInsertionEffect"]
    cat3 --> h3["useMemo<br/>useCallback<br/>useTransition<br/>useDeferredValue"]
    cat4 --> h4["useRef<br/>useImperativeHandle"]
    cat5 --> h5["useId<br/>useDebugValue<br/>useSyncExternalStore"]

フックの基本ルール

すべてのフックに共通する重要なルールを押さえておきましょう。

  • トップレベルで呼び出す: 条件分岐、ループ、ネストされた関数内ではフックを呼び出さない
  • React 関数内でのみ使用: 関数コンポーネントまたはカスタムフック内でのみ呼び出す
  • 呼び出し順序を保つ: 毎回のレンダリングで同じ順序でフックを呼び出す

これらのルールを守ることで、React が各フックの状態を正しく管理できるようになります。

具体例

それでは、各フックの具体的な使い方を見ていきましょう。カテゴリごとに、実践的なコード例とともに解説します。

状態管理フック

useState - シンプルな状態管理

useState は最もよく使われるフックで、コンポーネント内で状態を保持します。

typescriptimport { useState } from 'react';

function Counter() {
  // 初期値 0 でカウント状態を作成
  // count: 現在の状態値
  // setCount: 状態を更新する関数
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        増やす
      </button>
    </div>
  );
}

useState の基本形は const [state, setState] = useState(initialValue) です。配列の分割代入で、現在の値と更新関数を取得しています。

typescriptfunction Form() {
  // オブジェクトを状態として管理
  const [user, setUser] = useState({
    name: '',
    email: '',
  });

  // 特定のフィールドだけを更新する関数
  const handleNameChange = (e) => {
    setUser((prev) => ({
      ...prev, // 既存の値を展開
      name: e.target.value, // name だけを更新
    }));
  };

  return (
    <input
      value={user.name}
      onChange={handleNameChange}
      placeholder='名前を入力'
    />
  );
}

オブジェクトや配列を状態として扱う場合、スプレッド構文で既存の値をコピーしてから更新するのがポイントです。これにより、他のプロパティを保持したまま特定の値だけを変更できますね。

useReducer - 複雑な状態ロジックを管理

useReducer は、複数の状態が関連し合う場合や、状態更新のロジックが複雑な場合に適しています。

typescriptimport { useReducer } from 'react';

// 状態の型定義
type State = {
  count: number;
  step: number;
};

// アクションの型定義
type Action =
  | { type: 'increment' }
  | { type: 'decrement' }
  | { type: 'setStep'; payload: number };

まず、状態とアクションの型を定義します。TypeScript を使うことで、安全に状態管理ができるようになるのです。

typescript// Reducer 関数:現在の状態とアクションから新しい状態を計算
function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'increment':
      // step の値だけカウントを増やす
      return { ...state, count: state.count + state.step };
    case 'decrement':
      return { ...state, count: state.count - state.step };
    case 'setStep':
      // step の値を更新
      return { ...state, step: action.payload };
    default:
      return state;
  }
}

Reducer 関数は純粋関数として実装します。同じ入力に対して常に同じ出力を返すため、テストしやすく予測可能なコードになりますね。

typescriptfunction Counter() {
  // useReducer の初期化
  const [state, dispatch] = useReducer(reducer, {
    count: 0,
    step: 1,
  });

  return (
    <div>
      <p>カウント: {state.count}</p>
      <p>増減幅: {state.step}</p>

      {/* アクションをディスパッチして状態を更新 */}
      <button
        onClick={() => dispatch({ type: 'increment' })}
      >
        +{state.step}
      </button>

      <button
        onClick={() => dispatch({ type: 'decrement' })}
      >
        -{state.step}
      </button>

      <input
        type='number'
        value={state.step}
        onChange={(e) =>
          dispatch({
            type: 'setStep',
            payload: Number(e.target.value),
          })
        }
      />
    </div>
  );
}

useReducer を使うと、関連する状態をひとつのオブジェクトにまとめられます。複数の useState を使うよりもロジックが整理され、保守性が高まるでしょう。

useContext - グローバルな状態共有

useContext は、コンポーネントツリー全体で状態を共有したいときに使います。

typescriptimport { createContext, useContext, useState } from 'react';

// テーマの型定義
type Theme = 'light' | 'dark';

// Context の型定義
type ThemeContextType = {
  theme: Theme;
  toggleTheme: () => void;
};

Context の型を明確に定義することで、型安全性が保たれます。

typescript// Context の作成
const ThemeContext = createContext<
  ThemeContextType | undefined
>(undefined);

createContext でコンテキストを作成します。初期値は undefined にしておき、Provider で実際の値を提供する方法が一般的ですね。

typescript// Provider コンポーネント
function ThemeProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [theme, setTheme] = useState<Theme>('light');

  // テーマを切り替える関数
  const toggleTheme = () => {
    setTheme((prev) =>
      prev === 'light' ? 'dark' : 'light'
    );
  };

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

Provider コンポーネントで状態とその更新関数を提供します。この Provider で囲まれたすべての子コンポーネントから、テーマにアクセスできるようになるのです。

typescript// カスタムフックでContext を利用
function useTheme() {
  const context = useContext(ThemeContext);

  // Context が Provider 外で使われた場合のエラー処理
  if (context === undefined) {
    throw new Error(
      'useTheme は ThemeProvider 内で使用してください'
    );
  }

  return context;
}

カスタムフックを作ることで、Context の利用がより安全で簡潔になります。

typescript// Context を使用するコンポーネント
function ThemedButton() {
  const { theme, toggleTheme } = useTheme();

  return (
    <button
      onClick={toggleTheme}
      style={{
        background: theme === 'light' ? '#fff' : '#333',
        color: theme === 'light' ? '#333' : '#fff',
      }}
    >
      {theme === 'light'
        ? '🌙 ダークモード'
        : '☀️ ライトモード'}
    </button>
  );
}

どの階層のコンポーネントからでも、useTheme フックを使ってテーマにアクセスできます。props のバケツリレーが不要になるのが大きなメリットですね。

副作用フック

useEffect - 非同期の副作用処理

useEffect は、データ取得、DOM 操作、購読処理などの副作用を扱います。

typescriptimport { useState, useEffect } from 'react';

type User = {
  id: number;
  name: string;
  email: string;
};

まず、取得するデータの型を定義します。

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

  // userId が変わるたびにデータを取得
  useEffect(() => {
    // ローディング状態をリセット
    setLoading(true);
    setError(null);

    // データ取得の非同期関数
    async function fetchUser() {
      try {
        const response = await fetch(
          `https://api.example.com/users/${userId}`
        );

        if (!response.ok) {
          throw new Error(
            'ユーザー情報の取得に失敗しました'
          );
        }

        const data = await response.json();
        setUser(data);
      } catch (err) {
        setError(
          err instanceof Error
            ? err.message
            : '不明なエラー'
        );
      } finally {
        setLoading(false);
      }
    }

    fetchUser();
  }, [userId]); // userId が変わったら再実行

  if (loading) return <p>読み込み中...</p>;
  if (error) return <p>エラー: {error}</p>;
  if (!user) return <p>ユーザーが見つかりません</p>;

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

useEffect の第 2 引数(依存配列)に userId を指定することで、userId が変わったときだけデータを再取得します。この仕組みにより、無駄な API 呼び出しを防げるのです。

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

  useEffect(() => {
    // 1秒ごとにカウントアップするタイマー
    const intervalId = setInterval(() => {
      setSeconds((prev) => prev + 1);
    }, 1000);

    // クリーンアップ関数:コンポーネントのアンマウント時に実行
    return () => {
      clearInterval(intervalId);
      console.log('タイマーをクリア');
    };
  }, []); // 空配列 = マウント時に1回だけ実行

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

useEffect からクリーンアップ関数を返すことで、メモリリークを防げます。タイマーやイベントリスナーの解除は、この仕組みを使って行いましょう。

useLayoutEffect - DOM 変更直後の同期処理

useLayoutEffect は、DOM の変更が画面に反映される前に同期的に実行されます。

typescriptimport { useState, useLayoutEffect, useRef } from 'react';

function TooltipMeasure() {
  const [tooltipHeight, setTooltipHeight] = useState(0);
  const tooltipRef = useRef<HTMLDivElement>(null);

  // DOM 更新直後、ブラウザの描画前に実行
  useLayoutEffect(() => {
    if (tooltipRef.current) {
      // 要素の高さを測定
      const height =
        tooltipRef.current.getBoundingClientRect().height;
      setTooltipHeight(height);
    }
  }, []);

  return (
    <div>
      <div ref={tooltipRef} className='tooltip'>
        これはツールチップです
      </div>
      <p>ツールチップの高さ: {tooltipHeight}px</p>
    </div>
  );
}

useLayoutEffect を使うと、DOM のサイズを測定してから画面に表示するといった処理が可能です。ただし、同期的に実行されるためパフォーマンスへの影響があり、基本的には useEffect を使うことをお勧めします。

useInsertionEffect - CSS-in-JS のスタイル挿入

useInsertionEffect は、CSS-in-JS ライブラリが動的にスタイルを挿入するために使用します。

typescriptimport { useInsertionEffect } from 'react';

function useCSS(rule: string) {
  useInsertionEffect(() => {
    // <style> タグを作成してスタイルを挿入
    const style = document.createElement('style');
    style.textContent = rule;
    document.head.appendChild(style);

    // クリーンアップで <style> タグを削除
    return () => {
      document.head.removeChild(style);
    };
  }, [rule]);
}

このフックは主にライブラリ作者向けで、一般的なアプリケーション開発ではほとんど使いません。

パフォーマンス最適化フック

useMemo - 計算結果のメモ化

useMemo は、重い計算結果をキャッシュして不要な再計算を防ぎます。

typescriptimport { useState, useMemo } from 'react';

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

  // items または filter が変わった場合のみ再計算
  const filteredAndSorted = useMemo(() => {
    console.log('フィルタリングとソートを実行');

    return items
      .filter((item) => item.toString().includes(filter))
      .sort((a, b) => b - a); // 降順でソート
  }, [items, filter]);

  return (
    <div>
      <input
        value={filter}
        onChange={(e) => setFilter(e.target.value)}
        placeholder='数値でフィルタ'
      />

      <ul>
        {filteredAndSorted.map((item, index) => (
          <li key={index}>{item}</li>
        ))}
      </ul>
    </div>
  );
}

useMemo を使うことで、filter や items が変わらない限り、同じ計算結果を再利用できます。コンポーネントが再レンダリングされても無駄な計算は行われないのです。

typescriptfunction DataTable({
  data,
}: {
  data: Array<{ id: number; value: number }>;
}) {
  // 合計値を計算(data が変わったときだけ再計算)
  const total = useMemo(() => {
    console.log('合計を計算');
    return data.reduce((sum, item) => sum + item.value, 0);
  }, [data]);

  // 平均値を計算
  const average = useMemo(() => {
    console.log('平均を計算');
    return data.length > 0 ? total / data.length : 0;
  }, [data.length, total]);

  return (
    <div>
      <p>合計: {total}</p>
      <p>平均: {average.toFixed(2)}</p>
    </div>
  );
}

useMemo を使う際は、メモ化のコストと再計算のコストを比較しましょう。軽い計算なら useMemo を使わない方が良い場合もありますね。

useCallback - 関数のメモ化

useCallback は、関数自体をメモ化して、子コンポーネントへの不要な props 変更を防ぎます。

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

// React.memo で props が変わらない限り再レンダリングしない
const Button = memo(
  ({
    onClick,
    children,
  }: {
    onClick: () => void;
    children: React.ReactNode;
  }) => {
    console.log(`${children} ボタンがレンダリング`);
    return <button onClick={onClick}>{children}</button>;
  }
);

React.memo でラップされたコンポーネントは、props が変わらない限り再レンダリングされません。

typescriptfunction Parent() {
  const [count, setCount] = useState(0);
  const [otherState, setOtherState] = useState(0);

  // useCallback なしの場合、毎回新しい関数が作られる
  // const increment = () => setCount(c => c + 1);

  // useCallback でメモ化すると、依存配列が変わらない限り同じ関数を再利用
  const increment = useCallback(() => {
    setCount((c) => c + 1);
  }, []); // 依存なし = 常に同じ関数

  const handleOther = useCallback(() => {
    setOtherState((s) => s + 1);
  }, []);

  return (
    <div>
      <p>カウント: {count}</p>
      <p>その他: {otherState}</p>

      {/* increment が同じなら Button は再レンダリングされない */}
      <Button onClick={increment}>カウント+1</Button>
      <Button onClick={handleOther}>その他+1</Button>
    </div>
  );
}

useCallback を使うことで、関数の参照が変わらなくなります。これにより、React.memo でメモ化された子コンポーネントの不要な再レンダリングを防げるのです。

typescriptfunction SearchableList({ items }: { items: string[] }) {
  const [query, setQuery] = useState('');

  // query が変わったときだけ新しいフィルタ関数を作成
  const filterItems = useCallback(
    (item: string) => {
      return item
        .toLowerCase()
        .includes(query.toLowerCase());
    },
    [query]
  );

  const filteredItems = items.filter(filterItems);

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder='検索...'
      />
      <ul>
        {filteredItems.map((item) => (
          <li key={item}>{item}</li>
        ))}
      </ul>
    </div>
  );
}

useCallback は、関数を props として渡す場合や useEffect の依存配列に含める場合に特に有効です。

useTransition - 低優先度の状態更新

useTransition は、状態更新を低優先度として扱い、UI の応答性を保ちます。

typescriptimport { useState, useTransition } from 'react';

function SearchResults() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<string[]>([]);
  const [isPending, startTransition] = useTransition();

  // 検索処理
  const handleSearch = (value: string) => {
    // 入力値は即座に更新(高優先度)
    setQuery(value);

    // 検索結果の更新は低優先度
    startTransition(() => {
      // 重い処理をシミュレート
      const filtered = largeDataset.filter((item) =>
        item.toLowerCase().includes(value.toLowerCase())
      );
      setResults(filtered);
    });
  };

  return (
    <div>
      <input
        value={query}
        onChange={(e) => handleSearch(e.target.value)}
        placeholder='検索...'
      />

      {/* 処理中はローディング表示 */}
      {isPending && <p>検索中...</p>}

      <ul>
        {results.map((item, index) => (
          <li key={index}>{item}</li>
        ))}
      </ul>
    </div>
  );
}

// 大量のデータ
const largeDataset = Array.from(
  { length: 10000 },
  (_, i) => `アイテム ${i + 1}`
);

useTransition を使うと、入力フィールドは即座に更新され、重い検索処理は後回しになります。これにより、ユーザーの入力操作がブロックされず、滑らかな UI を維持できるのです。

useDeferredValue - 値の更新を遅延

useDeferredValue は、値の更新を遅延させて UI の応答性を向上させます。

typescriptimport { useState, useDeferredValue, useMemo } from 'react';

function SearchWithDeferred() {
  const [input, setInput] = useState('');

  // input の更新を遅延させた値
  const deferredInput = useDeferredValue(input);

  // 遅延された値を使って重い計算を実行
  const filteredResults = useMemo(() => {
    console.log('フィルタリング実行');
    return largeDataset.filter((item) =>
      item
        .toLowerCase()
        .includes(deferredInput.toLowerCase())
    );
  }, [deferredInput]);

  return (
    <div>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        placeholder='検索...'
      />

      {/* input と deferredInput が異なる場合は古いデータを表示中 */}
      {input !== deferredInput && <p>更新中...</p>}

      <ul>
        {filteredResults.map((item, index) => (
          <li key={index}>{item}</li>
        ))}
      </ul>
    </div>
  );
}

useDeferredValue を使うと、入力値の変更は即座に反映されますが、その値を使った重い処理は遅延されます。useTransition と似ていますが、こちらは値自体を遅延させる点が異なりますね。

参照・DOM 操作フック

useRef - DOM 要素への参照と値の保持

useRef は、DOM 要素への参照を取得したり、レンダリング間で値を保持したりします。

typescriptimport { useRef, useEffect } from 'react';

function FocusInput() {
  // HTMLInputElement への参照を作成
  const inputRef = useRef<HTMLInputElement>(null);

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

  return (
    <div>
      <input
        ref={inputRef}
        placeholder='自動フォーカスされます'
      />
    </div>
  );
}

useRef を使って DOM 要素にアクセスすると、命令的な操作が可能になります。フォーカス制御やスクロール位置の調整など、React の宣言的な仕組みでは難しい操作に適していますね。

typescriptfunction Stopwatch() {
  const [time, setTime] = useState(0);
  const [isRunning, setIsRunning] = useState(false);

  // タイマーID を保持(再レンダリングしても値は保持される)
  const intervalRef = useRef<number | null>(null);

  const start = () => {
    if (!isRunning) {
      setIsRunning(true);

      // setInterval の ID を ref に保存
      intervalRef.current = window.setInterval(() => {
        setTime((t) => t + 10);
      }, 10);
    }
  };

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

  const reset = () => {
    stop();
    setTime(0);
  };

  return (
    <div>
      <p>{(time / 1000).toFixed(2)}秒</p>
      <button onClick={start} disabled={isRunning}>
        開始
      </button>
      <button onClick={stop} disabled={!isRunning}>
        停止
      </button>
      <button onClick={reset}>リセット</button>
    </div>
  );
}

useRef は、再レンダリングをトリガーせずに値を保持できます。useState との違いは、ref の値を変更しても再レンダリングが発生しないことです。

typescriptfunction PreviousValue({ value }: { value: number }) {
  // 前回の値を保持
  const prevValueRef = useRef<number>();

  useEffect(() => {
    // レンダリング後に前回の値を更新
    prevValueRef.current = value;
  }, [value]);

  return (
    <div>
      <p>現在の値: {value}</p>
      <p>前回の値: {prevValueRef.current ?? '初回'}</p>
      <p>
        変化量:{' '}
        {prevValueRef.current
          ? value - prevValueRef.current
          : 0}
      </p>
    </div>
  );
}

useRef を使うと、前回のレンダリング時の値を保持できます。この技法は、値の変化を追跡する際に便利ですね。

useImperativeHandle - 親に公開する ref をカスタマイズ

useImperativeHandle は、forwardRef と組み合わせて、親コンポーネントに公開する ref の値をカスタマイズします。

typescriptimport {
  useRef,
  useImperativeHandle,
  forwardRef,
  ForwardedRef,
} from 'react';

// 親に公開するメソッドの型
type InputHandle = {
  focus: () => void;
  clear: () => void;
};

まず、親に公開するインターフェースを型定義します。

typescript// forwardRef でラップして ref を受け取れるようにする
const CustomInput = forwardRef(
  (
    props: { placeholder?: string },
    ref: ForwardedRef<InputHandle>
  ) => {
    const inputRef = useRef<HTMLInputElement>(null);

    // 親に公開するメソッドをカスタマイズ
    useImperativeHandle(ref, () => ({
      // フォーカスメソッド
      focus: () => {
        inputRef.current?.focus();
      },

      // クリアメソッド
      clear: () => {
        if (inputRef.current) {
          inputRef.current.value = '';
        }
      },
    }));

    return (
      <input
        ref={inputRef}
        placeholder={props.placeholder}
      />
    );
  }
);

useImperativeHandle を使うと、内部の実装を隠蔽しながら必要なメソッドだけを公開できます。

typescriptfunction Parent() {
  const inputRef = useRef<InputHandle>(null);

  const handleFocus = () => {
    inputRef.current?.focus();
  };

  const handleClear = () => {
    inputRef.current?.clear();
  };

  return (
    <div>
      <CustomInput
        ref={inputRef}
        placeholder='カスタム入力'
      />
      <button onClick={handleFocus}>フォーカス</button>
      <button onClick={handleClear}>クリア</button>
    </div>
  );
}

親コンポーネントからは、カスタマイズされたメソッドだけを呼び出せます。内部の DOM 要素に直接アクセスすることはできないため、カプセル化が保たれるのです。

ユーティリティフック

useId - 一意な ID の生成

useId は、サーバーとクライアント間で一貫した一意の ID を生成します。

typescriptimport { useId } from 'react';

function FormField({ label }: { label: string }) {
  // 一意な ID を生成
  const id = useId();

  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input id={id} />
    </div>
  );
}

useId を使うと、アクセシビリティのための ID を安全に生成できます。手動で ID を管理する必要がなくなり、重複の心配もありませんね。

typescriptfunction MultipleFields() {
  const id = useId();

  return (
    <form>
      <div>
        <label htmlFor={`${id}-name`}>名前</label>
        <input id={`${id}-name`} />
      </div>

      <div>
        <label htmlFor={`${id}-email`}>メール</label>
        <input id={`${id}-email`} type='email' />
      </div>

      <div>
        <label htmlFor={`${id}-message`}>メッセージ</label>
        <textarea id={`${id}-message`} />
      </div>
    </form>
  );
}

ひとつの useId から複数の ID を派生させることもできます。プレフィックスを付けることで、関連する要素に一貫性のある ID を割り当てられるのです。

useDebugValue - カスタムフックのデバッグ情報

useDebugValue は、React DevTools でカスタムフックのデバッグ情報を表示します。

typescriptimport { useState, useDebugValue } from 'react';

function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(
    navigator.onLine
  );

  // DevTools に表示されるラベル
  useDebugValue(isOnline ? 'オンライン' : 'オフライン');

  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  return isOnline;
}

useDebugValue を使うと、カスタムフックの状態が React DevTools で見やすくなります。

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

  // 複雑な値の場合、フォーマット関数を渡す(パフォーマンス最適化)
  useDebugValue(data, (d) =>
    d ? `データ取得完了: ${url}` : 'データなし'
  );

  useEffect(() => {
    fetch(url)
      .then((res) => res.json())
      .then(setData)
      .finally(() => setLoading(false));
  }, [url]);

  return { data, loading };
}

フォーマット関数を渡すことで、値が実際に DevTools で表示されるときだけ計算が実行されます。これにより、パフォーマンスへの影響を最小限に抑えられますね。

useSyncExternalStore - 外部ストアとの同期

useSyncExternalStore は、外部のストア(Redux、Zustand など)と React を同期させます。

typescriptimport { useSyncExternalStore } from 'react';

// シンプルなストアの実装例
class CounterStore {
  private count = 0;
  private listeners = new Set<() => void>();

  // 現在の値を取得
  getSnapshot = () => {
    return this.count;
  };

  // 購読(リスナーを登録)
  subscribe = (listener: () => void) => {
    this.listeners.add(listener);

    // 購読解除関数を返す
    return () => {
      this.listeners.delete(listener);
    };
  };

  // カウントを増やす
  increment = () => {
    this.count++;
    this.notifyListeners();
  };

  // すべてのリスナーに通知
  private notifyListeners = () => {
    this.listeners.forEach((listener) => listener());
  };
}

const counterStore = new CounterStore();

外部ストアには、getSnapshot と subscribe メソッドが必要です。

typescriptfunction Counter() {
  // 外部ストアと同期
  const count = useSyncExternalStore(
    counterStore.subscribe, // 購読関数
    counterStore.getSnapshot, // スナップショット取得関数
    () => 0 // サーバー側の初期値(オプション)
  );

  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={counterStore.increment}>+1</button>
    </div>
  );
}

useSyncExternalStore を使うことで、外部のストアが更新されたときに React コンポーネントが自動的に再レンダリングされます。この仕組みにより、状態管理ライブラリと React を安全に統合できるのです。

typescript// ブラウザ API との同期例
function useWindowSize() {
  const size = useSyncExternalStore(
    // ウィンドウのリサイズイベントを購読
    (callback) => {
      window.addEventListener('resize', callback);
      return () =>
        window.removeEventListener('resize', callback);
    },

    // 現在のウィンドウサイズを取得
    () => ({
      width: window.innerWidth,
      height: window.innerHeight,
    }),

    // サーバー側のデフォルト値
    () => ({ width: 0, height: 0 })
  );

  return size;
}

function WindowInfo() {
  const { width, height } = useWindowSize();

  return (
    <div>
      <p>幅: {width}px</p>
      <p>高さ: {height}px</p>
    </div>
  );
}

ブラウザ API のような外部の値を React と同期させる場合にも、useSyncExternalStore が活躍します。

まとめ

React フックは、シンプルな useState から高度な useTransition まで、さまざまな用途に対応しています。本記事では、15 種類のフックをカテゴリ別に分類し、それぞれの使い方を実例とともに解説しました。

フック選びで迷ったときは、以下のポイントを思い出してください。

  • シンプルな状態管理には useState を使いましょう
  • 複雑な状態ロジックには useReducer が適しています
  • グローバルな状態共有には useContext を活用できます
  • データ取得や購読には useEffect で副作用を処理します
  • パフォーマンス最適化には useMemo や useCallback を検討しましょう
  • UI の応答性向上には useTransition や useDeferredValue が役立ちます
  • DOM 操作や値の保持には useRef を使います

それぞれのフックには明確な役割があり、適切に使い分けることで、保守性が高く高パフォーマンスな React アプリケーションを構築できます。

最初はすべてのフックを使いこなす必要はありません。useState と useEffect から始めて、必要に応じて他のフックを学んでいくのが良いでしょう。実際のプロジェクトで課題に直面したときに、この記事を参照して適切なフックを見つけてくださいね。

React フックは、関数コンポーネントに強力な機能を与えてくれる素晴らしい仕組みです。本記事が、皆さんの React 開発の助けになれば幸いです。

関連リンク