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 - 状態管理の基本

ポイント: useStateはReact Hooksの最も基本的なフックです。状態値と更新関数のペアを返し、関数コンポーネントで状態管理を可能にします。

基本的な使い方

// 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}
 
 <button onClick={() => deleteTodo(todo.id)}>削除</button>
 </li>
 ))}
 </ul>
 </div>
 );
}

Step 2: useEffect - 副作用の処理

注意: useEffectの依存配列を正確に設定しないと、無限ループや古い値の参照が発生します。ESLintのexhaustive-depsルールを有効にしましょう。

基本的な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は重い計算のキャッシュに、useCallbackはmemo化された子コンポーネントへの関数渡しに使います。ただし過度な最適化は逆効果なので、まずプロファイラで計測してから適用しましょう。

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: カスタムフック - ロジックの再利用

カスタムフックを作ることで、コンポーネント間でロジックを再利用できます。名前は必ず「use」で始めます。

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ではなく計算で
  • オブジェクトの更新時はスプレッド演算子で
  1. useEffectの注意点
  • 依存配列を正確に設定
  • クリーンアップ関数を忘れずに
  • 無限ループに注意
  1. パフォーマンス最適化
  • 過度な最適化は避ける
  • まず計測してから最適化
  • memo, useMemo, useCallbackは必要な場所のみ
  1. カスタムフック
  • ロジックの再利用に活用
  • 名前は「use」で始める
  • 単一責任の原則を守る

よくある落とし穴と解決策

1. useEffectの無限ループ

// ❌ 無限ループになる
useEffect(() => {
 setCount(count + 1);
}); // 依存配列がない

// ✅ 正しい書き方
useEffect(() => {
 setCount(prev => prev + 1);
}, []); // 初回のみ実行

2. 古いクロージャ問題

// ❌ 古い値を参照してしまう
useEffect(() => {
 const interval = setInterval(() => {
 console.log(count); // 常に初期値が表示される
 }, 1000);
 return () => clearInterval(interval);
}, []);

// ✅ useRefで最新値を参照
const countRef = useRef(count);
useEffect(() => {
 countRef.current = count;
}, [count]);

useEffect(() => {
 const interval = setInterval(() => {
 console.log(countRef.current); // 常に最新値
 }, 1000);
 return () => clearInterval(interval);
}, []);

3. 過度なメモ化

// ❌ 不要なメモ化
const name = useMemo(() => "太郎", []); // 単純な値にuseMemoは不要

// ✅ 重い計算のみメモ化
const sortedItems = useMemo(() => {
 return items.sort((a, b) => a.price - b.price);
}, [items]);

関連記事

Reactをさらに深く学ぶために:

まとめ

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

無限ループや古いクロージャ問題に注意しながら、過度な最適化を避けてシンプルなコードを心がけることが、保守性の高いReactアプリケーション開発の鍵です

参考リソース

← 一覧に戻る
PR
PR
PR
PR