React Hooks完全マスター - 基本から応用まで

intermediate | 60分 で読める | 2024.12.15

このチュートリアルで学ぶこと

✓ useState - 状態管理の基本
✓ useEffect - 副作用の処理
✓ useContext - グローバル状態の共有
✓ useReducer - 複雑な状態管理
✓ useMemo/useCallback - パフォーマンス最適化
✓ カスタムフック - ロジックの再利用

前提条件

  • JavaScriptの基本知識
  • Reactの基本的な理解(コンポーネント、props)
  • Node.jsがインストールされていること

プロジェクトのセットアップ

# プロジェクト作成
npx create-react-app hooks-tutorial
cd hooks-tutorial

# 開発サーバー起動
npm start

Step 1: useState - 状態管理の基本

基本的な使い方

// src/components/Counter.jsx
import { useState } from 'react';

export default 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>
  );
}

オブジェクトの状態管理

// src/components/UserForm.jsx
import { useState } from 'react';

export default function UserForm() {
  const [user, setUser] = useState({
    name: '',
    email: '',
    age: ''
  });

  const handleChange = (e) => {
    const { name, value } = e.target;
    // スプレッド演算子で既存の値を保持しつつ更新
    setUser(prev => ({
      ...prev,
      [name]: value
    }));
  };

  return (
    <form>
      <input
        name="name"
        value={user.name}
        onChange={handleChange}
        placeholder="名前"
      />
      <input
        name="email"
        value={user.email}
        onChange={handleChange}
        placeholder="メール"
      />
      <input
        name="age"
        type="number"
        value={user.age}
        onChange={handleChange}
        placeholder="年齢"
      />
      <pre>{JSON.stringify(user, null, 2)}</pre>
    </form>
  );
}

配列の状態管理

// src/components/TodoList.jsx
import { useState } from 'react';

export default function TodoList() {
  const [todos, setTodos] = useState([]);
  const [input, setInput] = useState('');

  const addTodo = () => {
    if (!input.trim()) return;
    setTodos([...todos, { id: Date.now(), text: input, done: false }]);
    setInput('');
  };

  const toggleTodo = (id) => {
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, done: !todo.done } : todo
    ));
  };

  const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };

  return (
    <div>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        onKeyPress={(e) => e.key === 'Enter' && addTodo()}
      />
      <button onClick={addTodo}>追加</button>
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            <span
              style={{ textDecoration: todo.done ? 'line-through' : 'none' }}
              onClick={() => toggleTodo(todo.id)}
            >
              {todo.text}
            </span>
            <button onClick={() => deleteTodo(todo.id)}>削除</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Step 2: useEffect - 副作用の処理

基本的なuseEffect

// src/components/Timer.jsx
import { useState, useEffect } from 'react';

export default function Timer() {
  const [seconds, setSeconds] = useState(0);
  const [isRunning, setIsRunning] = useState(false);

  useEffect(() => {
    let interval = null;

    if (isRunning) {
      interval = setInterval(() => {
        setSeconds(prev => prev + 1);
      }, 1000);
    }

    // クリーンアップ関数
    return () => {
      if (interval) clearInterval(interval);
    };
  }, [isRunning]); // isRunningが変化したときに実行

  return (
    <div>
      <p>経過時間: {seconds}</p>
      <button onClick={() => setIsRunning(!isRunning)}>
        {isRunning ? '停止' : '開始'}
      </button>
      <button onClick={() => setSeconds(0)}>リセット</button>
    </div>
  );
}

データフェッチング

// src/components/UserList.jsx
import { useState, useEffect } from 'react';

export default function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchUsers = async () => {
      try {
        setLoading(true);
        const response = await fetch(
          'https://jsonplaceholder.typicode.com/users'
        );
        if (!response.ok) throw new Error('データの取得に失敗しました');
        const data = await response.json();
        setUsers(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchUsers();
  }, []); // 空の依存配列 = マウント時のみ実行

  if (loading) return <p>読み込み中...</p>;
  if (error) return <p>エラー: {error}</p>;

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

ローカルストレージとの同期

// src/components/PersistentCounter.jsx
import { useState, useEffect } from 'react';

export default function PersistentCounter() {
  const [count, setCount] = useState(() => {
    // 遅延初期化
    const saved = localStorage.getItem('count');
    return saved ? JSON.parse(saved) : 0;
  });

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

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

Step 3: useContext - グローバル状態の共有

Contextの作成と使用

// src/context/ThemeContext.jsx
import { createContext, useContext, useState } from 'react';

const ThemeContext = createContext();

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

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

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

export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within ThemeProvider');
  }
  return context;
}
// src/components/ThemedApp.jsx
import { useTheme } from '../context/ThemeContext';

export default function ThemedApp() {
  const { theme, toggleTheme } = useTheme();

  const styles = {
    container: {
      backgroundColor: theme === 'light' ? '#fff' : '#333',
      color: theme === 'light' ? '#333' : '#fff',
      padding: '20px',
      minHeight: '100vh'
    }
  };

  return (
    <div style={styles.container}>
      <h1>現在のテーマ: {theme}</h1>
      <button onClick={toggleTheme}>テーマ切替</button>
    </div>
  );
}
// src/App.jsx
import { ThemeProvider } from './context/ThemeContext';
import ThemedApp from './components/ThemedApp';

export default function App() {
  return (
    <ThemeProvider>
      <ThemedApp />
    </ThemeProvider>
  );
}

Step 4: useReducer - 複雑な状態管理

Reducerパターンの実装

// src/components/ShoppingCart.jsx
import { useReducer } from 'react';

const initialState = {
  items: [],
  total: 0
};

function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM': {
      const existingItem = state.items.find(
        item => item.id === action.payload.id
      );

      if (existingItem) {
        return {
          ...state,
          items: state.items.map(item =>
            item.id === action.payload.id
              ? { ...item, quantity: item.quantity + 1 }
              : item
          ),
          total: state.total + action.payload.price
        };
      }

      return {
        ...state,
        items: [...state.items, { ...action.payload, quantity: 1 }],
        total: state.total + action.payload.price
      };
    }

    case 'REMOVE_ITEM': {
      const item = state.items.find(i => i.id === action.payload);
      return {
        ...state,
        items: state.items.filter(i => i.id !== action.payload),
        total: state.total - (item.price * item.quantity)
      };
    }

    case 'CLEAR_CART':
      return initialState;

    default:
      return state;
  }
}

const products = [
  { id: 1, name: 'りんご', price: 100 },
  { id: 2, name: 'バナナ', price: 80 },
  { id: 3, name: 'オレンジ', price: 120 }
];

export default function ShoppingCart() {
  const [state, dispatch] = useReducer(cartReducer, initialState);

  return (
    <div>
      <h2>商品一覧</h2>
      {products.map(product => (
        <div key={product.id}>
          {product.name} - ¥{product.price}
          <button onClick={() => dispatch({
            type: 'ADD_ITEM',
            payload: product
          })}>
            カートに追加
          </button>
        </div>
      ))}

      <h2>カート</h2>
      {state.items.map(item => (
        <div key={item.id}>
          {item.name} x {item.quantity} = ¥{item.price * item.quantity}
          <button onClick={() => dispatch({
            type: 'REMOVE_ITEM',
            payload: item.id
          })}>
            削除
          </button>
        </div>
      ))}

      <p>合計: ¥{state.total}</p>
      <button onClick={() => dispatch({ type: 'CLEAR_CART' })}>
        カートを空にする
      </button>
    </div>
  );
}

Step 5: useMemo / useCallback - パフォーマンス最適化

useMemo - 計算結果のメモ化

// src/components/ExpensiveCalculation.jsx
import { useState, useMemo } from 'react';

function slowFunction(num) {
  console.log('計算中...');
  for (let i = 0; i < 1000000000; i++) {} // 重い処理
  return num * 2;
}

export default function ExpensiveCalculation() {
  const [number, setNumber] = useState(0);
  const [dark, setDark] = useState(false);

  // numberが変わったときだけ再計算
  const doubledNumber = useMemo(() => {
    return slowFunction(number);
  }, [number]);

  const theme = {
    backgroundColor: dark ? '#333' : '#fff',
    color: dark ? '#fff' : '#333'
  };

  return (
    <div style={theme}>
      <input
        type="number"
        value={number}
        onChange={(e) => setNumber(parseInt(e.target.value) || 0)}
      />
      <p>結果: {doubledNumber}</p>
      <button onClick={() => setDark(!dark)}>
        テーマ切替
      </button>
    </div>
  );
}

useCallback - 関数のメモ化

// src/components/CallbackExample.jsx
import { useState, useCallback, memo } from 'react';

// memo化された子コンポーネント
const SearchButton = memo(({ onClick }) => {
  console.log('SearchButtonがレンダリングされました');
  return <button onClick={onClick}>検索</button>;
});

export default function CallbackExample() {
  const [query, setQuery] = useState('');
  const [count, setCount] = useState(0);

  // queryが変わったときだけ新しい関数を作成
  const handleSearch = useCallback(() => {
    console.log(`検索: ${query}`);
  }, [query]);

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="検索ワード"
      />
      <SearchButton onClick={handleSearch} />

      <p>カウント: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        カウント増加(これでSearchButtonは再レンダリングされない)
      </button>
    </div>
  );
}

Step 6: カスタムフック - ロジックの再利用

useLocalStorage

// src/hooks/useLocalStorage.js
import { useState, useEffect } from 'react';

export function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const item = localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });

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

  return [value, setValue];
}

useFetch

// src/hooks/useFetch.js
import { useState, useEffect } from 'react';

export function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

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

    const fetchData = async () => {
      try {
        setLoading(true);
        const response = await fetch(url, {
          signal: abortController.signal
        });
        if (!response.ok) throw new Error('Network error');
        const json = await response.json();
        setData(json);
        setError(null);
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        setLoading(false);
      }
    };

    fetchData();

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

  return { data, loading, error };
}

useDebounce

// src/hooks/useDebounce.js
import { useState, useEffect } from 'react';

export function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

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

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

  return debouncedValue;
}

カスタムフックの使用例

// src/components/SearchWithDebounce.jsx
import { useState } from 'react';
import { useDebounce } from '../hooks/useDebounce';
import { useFetch } from '../hooks/useFetch';

export default function SearchWithDebounce() {
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedSearch = useDebounce(searchTerm, 500);

  const { data, loading, error } = useFetch(
    debouncedSearch
      ? `https://api.github.com/search/users?q=${debouncedSearch}`
      : null
  );

  return (
    <div>
      <input
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="GitHubユーザーを検索..."
      />

      {loading && <p>検索中...</p>}
      {error && <p>エラー: {error}</p>}
      {data?.items?.map(user => (
        <div key={user.id}>
          <img src={user.avatar_url} alt="" width={32} />
          {user.login}
        </div>
      ))}
    </div>
  );
}

ベストプラクティス

1. useStateの注意点
   - 状態は必要最小限に
   - 派生値はuseStateではなく計算で
   - オブジェクトの更新時はスプレッド演算子で

2. useEffectの注意点
   - 依存配列を正確に設定
   - クリーンアップ関数を忘れずに
   - 無限ループに注意

3. パフォーマンス最適化
   - 過度な最適化は避ける
   - まず計測してから最適化
   - memo, useMemo, useCallbackは必要な場所のみ

4. カスタムフック
   - ロジックの再利用に活用
   - 名前は「use」で始める
   - 単一責任の原則を守る

まとめ

React Hooksを使うことで、関数コンポーネントで状態管理や副作用を扱えるようになります。基本のuseState、useEffectをマスターした後、useContext、useReducerで複雑な状態管理に挑戦し、useMemo、useCallbackでパフォーマンスを最適化しましょう。

← 一覧に戻る