React Hooks完全ガイド|useState・useEffect・カスタムフックの実践

kento_morota 29分で読めます

React Hooksは、関数コンポーネントで状態管理や副作用処理を行うための仕組みです。2019年にReact 16.8で導入されて以降、クラスコンポーネントに代わるReact開発の標準となりました。

本記事では、useState、useEffect、useRef、useMemo、useCallbackなど主要なフックの使い方と、再利用可能なカスタムフックの作り方を、実務での具体例とともに解説します。

Hooksの基本ルールと仕組み

Hooksを正しく使うためには、2つの基本ルールを理解しておく必要があります。

ルール1:トップレベルでのみ呼び出す - Hooksは条件分岐、ループ、ネストされた関数の中で呼び出してはいけません。コンポーネントの最上位でのみ使用します。

ルール2:React関数内でのみ呼び出す - Hooksは関数コンポーネントまたはカスタムフック内でのみ使用できます。通常のJavaScript関数では使えません。

// 正しい使い方
function UserProfile() {
  const [name, setName] = useState("");     // OK: トップレベル
  const [age, setAge] = useState(0);        // OK: トップレベル

  // 誤った使い方の例
  // if (name) {
  //   const [error, setError] = useState(""); // NG: 条件分岐内
  // }

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

Reactは内部的にHooksの呼び出し順序を記録しているため、呼び出し順序が変わると状態の対応が崩れてしまいます。

useStateによる状態管理

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(0)}>リセット</button>
    </div>
  );
}

オブジェクト・配列の状態管理

interface FormData {
  name: string;
  email: string;
  message: string;
}

function ContactForm() {
  const [formData, setFormData] = useState<FormData>({
    name: "",
    email: "",
    message: "",
  });

  const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});

  // スプレッド構文で特定フィールドだけ更新
  const handleChange = (field: keyof FormData, value: string) => {
    setFormData((prev) => ({ ...prev, [field]: value }));
    // エラーをクリア
    setErrors((prev) => ({ ...prev, [field]: undefined }));
  };

  return (
    <form>
      <input
        value={formData.name}
        onChange={(e) => handleChange("name", e.target.value)}
        placeholder="名前"
      />
      {errors.name && <span className="error">{errors.name}</span>}

      <input
        value={formData.email}
        onChange={(e) => handleChange("email", e.target.value)}
        placeholder="メールアドレス"
      />

      <textarea
        value={formData.message}
        onChange={(e) => handleChange("message", e.target.value)}
        placeholder="メッセージ"
      />
    </form>
  );
}

配列のイミュータブルな更新

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

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

  // 追加
  const addTodo = (text: string) => {
    setTodos((prev) => [
      ...prev,
      { id: Date.now(), text, completed: false },
    ]);
  };

  // 更新(特定のアイテムだけ変更)
  const toggleTodo = (id: number) => {
    setTodos((prev) =>
      prev.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };

  // 削除
  const deleteTodo = (id: number) => {
    setTodos((prev) => prev.filter((todo) => todo.id !== id));
  };

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>
          <input
            type="checkbox"
            checked={todo.completed}
            onChange={() => toggleTodo(todo.id)}
          />
          <span style={{ textDecoration: todo.completed ? "line-through" : "none" }}>
            {todo.text}
          </span>
          <button onClick={() => deleteTodo(todo.id)}>削除</button>
        </li>
      ))}
    </ul>
  );
}

useEffectによる副作用の処理

useEffectは、データの取得、DOM操作、タイマー、イベントリスナーの登録など、レンダリング以外の処理(副作用)を行うフックです。

依存配列の使い分け

import { useState, useEffect } from "react";

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

  // 依存配列にuserIdを指定 → userIdが変わるたびに再実行
  useEffect(() => {
    let cancelled = false;

    async function fetchUser() {
      setLoading(true);
      try {
        const res = await fetch(`/api/users/${userId}`);
        const data = await res.json();
        if (!cancelled) {
          setUser(data);
        }
      } catch (error) {
        console.error("Failed to fetch user:", error);
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    }

    fetchUser();

    // クリーンアップ関数:コンポーネントのアンマウント時や依存値の変更時に実行
    return () => {
      cancelled = true;
    };
  }, [userId]); // userIdが変わったときだけ再実行

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

イベントリスナーの管理

function WindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    const handleResize = () => {
      setSize({ width: window.innerWidth, height: window.innerHeight });
    };

    window.addEventListener("resize", handleResize);

    // クリーンアップでイベントリスナーを解除(メモリリーク防止)
    return () => {
      window.removeEventListener("resize", handleResize);
    };
  }, []); // 空配列 → マウント時に1回だけ実行

  return (
    <p>
      画面サイズ: {size.width} x {size.height}
    </p>
  );
}

useEffectの依存配列パターンまとめ

// 1. 毎回のレンダリング後に実行(依存配列なし)
useEffect(() => {
  console.log("毎回実行");
});

// 2. マウント時に1回だけ実行(空の依存配列)
useEffect(() => {
  console.log("マウント時のみ");
  return () => console.log("アンマウント時のクリーンアップ");
}, []);

// 3. 特定の値が変化したときに実行
useEffect(() => {
  console.log(`userIdが${userId}に変更`);
}, [userId]);

// 4. 複数の依存値
useEffect(() => {
  console.log("pageまたはfilterが変更");
}, [page, filter]);

useRef・useMemo・useCallbackの活用

useRef:再レンダリングを起こさない値の保持

import { useRef, useEffect } from "react";

// DOM要素への参照
function AutoFocusInput() {
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  return <input ref={inputRef} placeholder="自動フォーカス" />;
}

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

  useEffect(() => {
    ref.current = value;
  });

  return ref.current;
}

// タイマーの管理
function StopWatch() {
  const [time, setTime] = useState(0);
  const [isRunning, setIsRunning] = useState(false);
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);

  useEffect(() => {
    if (isRunning) {
      intervalRef.current = setInterval(() => {
        setTime((prev) => prev + 10);
      }, 10);
    } else if (intervalRef.current) {
      clearInterval(intervalRef.current);
    }

    return () => {
      if (intervalRef.current) clearInterval(intervalRef.current);
    };
  }, [isRunning]);

  return (
    <div>
      <p>{(time / 1000).toFixed(2)}秒</p>
      <button onClick={() => setIsRunning(!isRunning)}>
        {isRunning ? "停止" : "開始"}
      </button>
      <button onClick={() => { setTime(0); setIsRunning(false); }}>
        リセット
      </button>
    </div>
  );
}

useMemo:計算結果のメモ化

import { useMemo, useState } from "react";

interface Product {
  id: number;
  name: string;
  price: number;
  category: string;
}

function ProductList({ products }: { products: Product[] }) {
  const [filter, setFilter] = useState("");
  const [sortBy, setSortBy] = useState<"name" | "price">("name");

  // フィルタリング・ソートの結果をメモ化
  // productsかfilterかsortByが変わったときだけ再計算
  const filteredProducts = useMemo(() => {
    console.log("フィルタリング実行"); // 不要な再計算を検出
    return products
      .filter((p) =>
        p.name.toLowerCase().includes(filter.toLowerCase()) ||
        p.category.toLowerCase().includes(filter.toLowerCase())
      )
      .sort((a, b) => {
        if (sortBy === "price") return a.price - b.price;
        return a.name.localeCompare(b.name);
      });
  }, [products, filter, sortBy]);

  // 統計情報のメモ化
  const stats = useMemo(() => ({
    total: filteredProducts.length,
    averagePrice: filteredProducts.length > 0
      ? Math.floor(
          filteredProducts.reduce((sum, p) => sum + p.price, 0) /
            filteredProducts.length
        )
      : 0,
  }), [filteredProducts]);

  return (
    <div>
      <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="検索" />
      <p>{stats.total}件 / 平均価格: ¥{stats.averagePrice}</p>
      {filteredProducts.map((product) => (
        <div key={product.id}>{product.name}: ¥{product.price}</div>
      ))}
    </div>
  );
}

useCallback:関数のメモ化

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

// memo化された子コンポーネント
const TodoItem = memo(function TodoItem({
  todo,
  onToggle,
  onDelete,
}: {
  todo: Todo;
  onToggle: (id: number) => void;
  onDelete: (id: number) => void;
}) {
  console.log(`TodoItem rendered: ${todo.text}`);
  return (
    <li>
      <input type="checkbox" checked={todo.completed} onChange={() => onToggle(todo.id)} />
      {todo.text}
      <button onClick={() => onDelete(todo.id)}>削除</button>
    </li>
  );
});

function TodoApp() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [input, setInput] = useState("");

  // useCallbackでメモ化 → TodoItemの不要な再レンダリングを防止
  const handleToggle = useCallback((id: number) => {
    setTodos((prev) =>
      prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
    );
  }, []);

  const handleDelete = useCallback((id: number) => {
    setTodos((prev) => prev.filter((t) => t.id !== id));
  }, []);

  const handleAdd = () => {
    if (!input.trim()) return;
    setTodos((prev) => [...prev, { id: Date.now(), text: input, completed: false }]);
    setInput("");
  };

  return (
    <div>
      <input value={input} onChange={(e) => setInput(e.target.value)} />
      <button onClick={handleAdd}>追加</button>
      <ul>
        {todos.map((todo) => (
          <TodoItem
            key={todo.id}
            todo={todo}
            onToggle={handleToggle}
            onDelete={handleDelete}
          />
        ))}
      </ul>
    </div>
  );
}

useReducerによる複雑な状態管理

useReducerは、複数の関連する状態をまとめて管理する場合や、状態遷移のロジックが複雑な場合に適しています。

import { useReducer } from "react";

// 状態の型定義
interface FetchState<T> {
  data: T | null;
  isLoading: boolean;
  error: string | null;
}

// アクションの型定義
type FetchAction<T> =
  | { type: "FETCH_START" }
  | { type: "FETCH_SUCCESS"; payload: T }
  | { type: "FETCH_ERROR"; payload: string }
  | { type: "RESET" };

// リデューサー関数
function fetchReducer<T>(state: FetchState<T>, action: FetchAction<T>): FetchState<T> {
  switch (action.type) {
    case "FETCH_START":
      return { ...state, isLoading: true, error: null };
    case "FETCH_SUCCESS":
      return { data: action.payload, isLoading: false, error: null };
    case "FETCH_ERROR":
      return { ...state, isLoading: false, error: action.payload };
    case "RESET":
      return { data: null, isLoading: false, error: null };
    default:
      return state;
  }
}

// 使用例
function UserProfile({ userId }: { userId: number }) {
  const [state, dispatch] = useReducer(fetchReducer<User>, {
    data: null,
    isLoading: false,
    error: null,
  });

  useEffect(() => {
    dispatch({ type: "FETCH_START" });

    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => dispatch({ type: "FETCH_SUCCESS", payload: data }))
      .catch((err) => dispatch({ type: "FETCH_ERROR", payload: err.message }));
  }, [userId]);

  if (state.isLoading) return <p>読み込み中...</p>;
  if (state.error) return <p>エラー: {state.error}</p>;
  if (!state.data) return null;

  return <div>{state.data.name}</div>;
}

カスタムフックの作成

カスタムフックは、Hooksのロジックを再利用可能な関数として切り出す仕組みです。名前をuseから始める慣例があります。

useFetch:データ取得フック

import { useState, useEffect, useCallback } from "react";

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

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

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

    try {
      const res = await fetch(url);
      if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
      const json = await res.json();
      setData(json);
    } catch (err) {
      setError(err instanceof Error ? err.message : "Unknown error");
    } finally {
      setIsLoading(false);
    }
  }, [url]);

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

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

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

  if (isLoading) return <p>読み込み中...</p>;
  if (error) return <p>エラー: {error} <button onClick={refetch}>再試行</button></p>;

  return (
    <ul>
      {users?.map((user) => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
}

useLocalStorage:ローカルストレージと同期するフック

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

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

  return [storedValue, setValue];
}

// 使用例
function Settings() {
  const [theme, setTheme] = useLocalStorage<"light" | "dark">("theme", "light");
  const [fontSize, setFontSize] = useLocalStorage("fontSize", 16);

  return (
    <div>
      <select value={theme} onChange={(e) => setTheme(e.target.value as "light" | "dark")}>
        <option value="light">ライト</option>
        <option value="dark">ダーク</option>
      </select>
      <input
        type="range"
        min={12}
        max={24}
        value={fontSize}
        onChange={(e) => setFontSize(Number(e.target.value))}
      />
    </div>
  );
}

useDebounce:入力の遅延処理フック

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;
}

// 使用例:検索入力のデバウンス
function SearchBox() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 300);
  const { data: results } = useFetch<Product[]>(
    debouncedQuery ? `/api/search?q=${debouncedQuery}` : ""
  );

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="検索..." />
      {results?.map((r) => <div key={r.id}>{r.name}</div>)}
    </div>
  );
}

まとめ

React Hooksは、関数コンポーネントに状態管理と副作用処理の能力を与える仕組みです。各フックの使い分けを整理します。

useState:コンポーネントの状態を管理します。オブジェクトや配列はイミュータブルに更新することが重要です。

useEffect:データ取得、イベントリスナー、タイマーなどの副作用を処理します。クリーンアップ関数でリソースリークを防ぎます。

useRef:DOM参照や、レンダリングを起こさない値の保持に使います。

useMemo・useCallback:計算結果や関数をメモ化してパフォーマンスを最適化します。ただし、不要な場面での使用は避けましょう。

useReducer:複雑な状態遷移をリデューサー関数で管理します。useStateでは扱いきれない状態に有効です。

カスタムフック:ロジックを再利用可能な関数に切り出します。useFetch、useLocalStorage、useDebounceなど、プロジェクト共通のフックを作ることで開発効率が上がります。

まずはuseStateとuseEffectを確実に使いこなし、必要に応じてuseMemo・useCallback・カスタムフックを導入していくのが効果的です。

#React#Hooks#フロントエンド
共有:
無料メルマガ

週1回、最新の技術記事をお届け

AI・クラウド・開発の最新記事を毎週月曜にメールでお届けします。登録は無料、いつでも解除できます。

プライバシーポリシーに基づき管理します

起業準備に役立つ情報、もっとありますよ。

まずは話だけ聞いてもらう