TechHub

エンジニアの成長をサポートする技術情報サイト

← 記事一覧に戻る

React Hooksとは?useStateとuseEffectの使い方

公開日: 2024年2月8日 著者: mogura
React Hooksとは?useStateとuseEffectの使い方

疑問

React Hooksとは何で、どのように使用するのでしょうか?useStateとuseEffectの基本的な使い方から、カスタムフックの作成まで一緒に学んでいきましょう。

導入

React Hooksは、関数コンポーネントで状態管理やライフサイクル機能を使用できるようにする機能です。React 16.8で導入され、関数コンポーネントをより強力で柔軟にしました。

本記事では、React Hooksの基本概念から、useState、useEffect、カスタムフックの作成まで、実践的なコード例とともに詳しく解説していきます。

React Hooksのイメージ

解説

1. React Hooksとは

React Hooksは、関数コンポーネントでReactの機能(状態管理、ライフサイクルなど)を使用できるようにする関数です。

Hooksのルール

1. トップレベルでのみ呼び出す: ループ、条件分岐、ネストした関数内では呼び出さない
2. 関数コンポーネントまたはカスタムフックでのみ使用: 通常のJavaScript関数では使用しない

2. useStateフック

useStateは、関数コンポーネントで状態を管理するためのフックです。

基本的な使い方

useStateは、現在の状態値と、それを更新する関数のペアを返します。初期値を引数として渡すことができ、状態が更新されるとコンポーネントが再レンダリングされます。

複数の状態を管理

複数の状態を管理する場合は、useStateを複数回呼び出します。各状態は独立して管理され、個別に更新できます。

オブジェクトの状態管理

オブジェクトを状態として管理する場合、スプレッド演算子を使用して既存の状態を保持します。これにより、オブジェクトの一部のみを更新できます。

関数型更新

前の状態値に基づいて状態を更新する場合は、関数型更新を使用します。これにより、最新の状態値を確実に取得できます。

useStateの実装例

この例では、useStateの基本的な使い方から、複数の状態管理、オブジェクトの状態管理、関数型更新までを示しています。

import React, { useState } from 'react';

// 基本的な使い方
function Counter() {
    const [count, setCount] = useState(0);
    
    return (
        <div>
            <p>カウント: {count}</p>
            <button onClick={() => setCount(count + 1)}>
                増やす
            </button>
            <button onClick={() => setCount(count - 1)}>
                減らす
            </button>
        </div>
    );
}

// 複数の状態を管理
function Form() {
    const [name, setName] = useState('');
    const [email, setEmail] = useState('');
    const [age, setAge] = useState(0);
    
    return (
        <form>
            <input
                type="text"
                value={name}
                onChange={(e) => setName(e.target.value)}
                placeholder="名前"
            />
            <input
                type="email"
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                placeholder="メール"
            />
            <input
                type="number"
                value={age}
                onChange={(e) => setAge(Number(e.target.value))}
                placeholder="年齢"
            />
        </form>
    );
}

// オブジェクトの状態管理
function UserProfile() {
    const [user, setUser] = useState({
        name: '',
        email: '',
        age: 0
    });
    
    const updateName = (newName) => {
        setUser({ ...user, name: newName });
    };
    
    return (
        <div>
            <input
                value={user.name}
                onChange={(e) => updateName(e.target.value)}
            />
        </div>
    );
}

// 関数型更新
function CounterWithFunction() {
    const [count, setCount] = useState(0);
    
    const increment = () => {
        setCount(prevCount => prevCount + 1);
    };
    
    const incrementByTwo = () => {
        setCount(prevCount => prevCount + 1);
        setCount(prevCount => prevCount + 1);
    };
    
    return (
        <div>
            <p>カウント: {count}</p>
            <button onClick={increment}>+1</button>
            <button onClick={incrementByTwo}>+2</button>
        </div>
    );
}

3. useEffectフック

useEffectは、関数コンポーネントで副作用(side effects)を実行するためのフックです。データの取得、DOMの操作、イベントリスナーの登録などに使用されます。

基本的な使い方

useEffectは、コンポーネントのレンダリング後に実行される関数を定義します。第1引数に実行する関数を、第2引数に依存配列を渡します。依存配列を省略すると、毎回のレンダリング後に実行されます。

クリーンアップ関数

useEffect内でクリーンアップ関数を返すことで、コンポーネントのアンマウント時や再レンダリング前にクリーンアップ処理を実行できます。タイマーのクリア、イベントリスナーの削除、APIリクエストのキャンセルなどに使用されます。

イベントリスナーのクリーンアップ

イベントリスナーを登録した場合、クリーンアップ関数で削除する必要があります。これにより、メモリリークを防ぎ、不要なイベントリスナーが残らないようにします。

依存配列の使い方

依存配列を使用することで、特定の値が変更された場合のみuseEffectを実行できます。空の配列`[]`を渡すと、マウント時のみ実行されます。依存配列に値を指定すると、その値が変更された場合にのみ実行されます。

useEffectの実装例

この例では、useEffectの基本的な使い方、データ取得、イベントリスナーの登録とクリーンアップを示しています。

import React, { useState, useEffect } from 'react';

// 基本的な使い方
function Timer() {
    const [seconds, setSeconds] = useState(0);
    
    useEffect(() => {
        const interval = setInterval(() => {
            setSeconds(prev => prev + 1);
        }, 1000);
        
        // クリーンアップ関数
        return () => clearInterval(interval);
    }, []); // マウント時のみ実行
    
    return <div>経過時間: {seconds}秒</div>;
}

// データ取得の例
function UserProfile({ userId }) {
    const [user, setUser] = useState(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('エラー:', error);
            } finally {
                setLoading(false);
            }
        }
        
        fetchUser();
    }, [userId]); // userIdが変更された場合のみ実行
    
    if (loading) return <div>読み込み中...</div>;
    if (!user) return <div>ユーザーが見つかりません</div>;
    
    return <div>{user.name}</div>;
}

// イベントリスナーの例
function WindowSize() {
    const [windowSize, setWindowSize] = useState({
        width: window.innerWidth,
        height: window.innerHeight
    });
    
    useEffect(() => {
        function handleResize() {
            setWindowSize({
                width: window.innerWidth,
                height: window.innerHeight
            });
        }
        
        window.addEventListener('resize', handleResize);
        
        // クリーンアップ関数でイベントリスナーを削除
        return () => window.removeEventListener('resize', handleResize);
    }, []);
    
    return (
        <div>
            ウィンドウサイズ: {windowSize.width} x {windowSize.height}
        </div>
    );
}

4. useContextフック

useContextは、React Contextを使用して、コンポーネントツリー全体でデータを共有するためのフックです。プロップドリリングを避け、深い階層のコンポーネントにデータを渡すことができます。

Contextの作成と使用

Contextを作成し、Providerで値を提供し、useContextで値を取得します。これにより、propsを何層も渡すことなく、必要なコンポーネントで直接データにアクセスできます。

useContextのメリット

  • プロップドリリングの回避: 中間のコンポーネントを経由せずに、必要なコンポーネントで直接データにアクセスできます。
  • グローバル状態の管理: テーマ、認証情報、言語設定など、アプリケーション全体で共有するデータを管理できます。
  • コードの簡潔性: propsを何層も渡す必要がなくなり、コードが簡潔になります。

useContextの実装例

この例では、useContextを使用してテーマを管理する方法を示しています。

import React, { createContext, useContext, useState } from 'react';

// Contextの作成
const ThemeContext = createContext();

// Providerコンポーネント
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>
    );
}

// useContextを使用するカスタムフック
function useTheme() {
    const context = useContext(ThemeContext);
    if (!context) {
        throw new Error('useThemeはThemeProvider内で使用する必要があります');
    }
    return context;
}

// 使用例
function App() {
    return (
        <ThemeProvider>
            <Header />
            <Main />
        </ThemeProvider>
    );
}

function Header() {
    const { theme, toggleTheme } = useTheme();
    
    return (
        <header style={{ background: theme === 'light' ? '#fff' : '#333' }}>
            <button onClick={toggleTheme}>テーマを切り替え</button>
        </header>
    );
}

function Main() {
    const { theme } = useTheme();
    
    return (
        <main style={{ background: theme === 'light' ? '#f5f5f5' : '#222' }}>
            メインコンテンツ
        </main>
    );
}

5. useReducerフック

useReducerは、複雑な状態ロジックを管理するためのフックです。useStateの代替として使用でき、状態の更新ロジックをreducer関数に集約できます。

useReducerの基本

useReducerは、reducer関数と初期状態を受け取り、現在の状態とdispatch関数を返します。reducer関数は、現在の状態とアクションを受け取り、新しい状態を返します。複雑な状態ロジックや、複数の状態が関連している場合に適しています。

useReducerのメリット

  • 複雑な状態ロジックの管理: 状態の更新ロジックをreducer関数に集約することで、コードが整理されます。
  • 予測可能な状態更新: アクションに基づいて状態を更新するため、状態の変化が追跡しやすくなります。
  • テストの容易さ: reducer関数は純粋関数なので、テストが容易です。

useReducerの実装例

この例では、useReducerを使用したカウンターとTodoアプリの実装を示しています。

import React, { useReducer } from 'react';

// 初期状態
const initialState = { count: 0 };

// reducer関数
function counterReducer(state, action) {
    switch (action.type) {
        case 'increment':
            return { count: state.count + 1 };
        case 'decrement':
            return { count: state.count - 1 };
        case 'reset':
            return { count: 0 };
        case 'incrementByAmount':
            return { count: state.count + action.payload };
        default:
            throw new Error(`不明なアクション: ${action.type}`);
    }
}

// コンポーネント
function Counter() {
    const [state, dispatch] = useReducer(counterReducer, initialState);
    
    return (
        <div>
            <p>カウント: {state.count}</p>
            <button onClick={() => dispatch({ type: 'increment' })}>
                +1
            </button>
            <button onClick={() => dispatch({ type: 'decrement' })}>
                -1
            </button>
            <button onClick={() => dispatch({ type: 'reset' })}>
                リセット
            </button>
            <button onClick={() => dispatch({ type: 'incrementByAmount', payload: 5 })}>
                +5
            </button>
        </div>
    );
}

// より複雑な例:Todoアプリ
const todoInitialState = {
    todos: [],
    filter: 'all'
};

function todoReducer(state, action) {
    switch (action.type) {
        case 'add':
            return {
                ...state,
                todos: [...state.todos, { id: Date.now(), text: action.payload, completed: false }]
            };
        case 'toggle':
            return {
                ...state,
                todos: state.todos.map(todo =>
                    todo.id === action.payload
                        ? { ...todo, completed: !todo.completed }
                        : todo
                )
            };
        case 'delete':
            return {
                ...state,
                todos: state.todos.filter(todo => todo.id !== action.payload)
            };
        case 'setFilter':
            return {
                ...state,
                filter: action.payload
            };
        default:
            return state;
    }
}

function TodoApp() {
    const [state, dispatch] = useReducer(todoReducer, todoInitialState);
    
    const filteredTodos = state.todos.filter(todo => {
        if (state.filter === 'active') return !todo.completed;
        if (state.filter === 'completed') return todo.completed;
        return true;
    });
    
    return (
        <div>
            <input
                type="text"
                onKeyPress={(e) => {
                    if (e.key === 'Enter') {
                        dispatch({ type: 'add', payload: e.target.value });
                        e.target.value = '';
                    }
                }}
            />
            <div>
                <button onClick={() => dispatch({ type: 'setFilter', payload: 'all' })}>
                    すべて
                </button>
                <button onClick={() => dispatch({ type: 'setFilter', payload: 'active' })}>
                    未完了
                </button>
                <button onClick={() => dispatch({ type: 'setFilter', payload: 'completed' })}>
                    完了
                </button>
            </div>
            <ul>
                {filteredTodos.map(todo => (
                    <li key={todo.id}>
                        <span
                            onClick={() => dispatch({ type: 'toggle', payload: todo.id })}
                            style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
                        >
                            {todo.text}
                        </span>
                        <button onClick={() => dispatch({ type: 'delete', payload: todo.id })}>
                            削除
                        </button>
                    </li>
                ))}
            </ul>
        </div>
    );
}

6. useMemoとuseCallback

useMemoとuseCallbackは、パフォーマンス最適化のためのフックです。useMemoは計算結果をメモ化し、useCallbackは関数をメモ化します。

useMemo(メモ化された値)

useMemoは、計算コストが高い値の計算結果をメモ化します。依存配列の値が変更された場合のみ再計算されます。不要な再計算を避けることで、パフォーマンスを向上させます。ただし、過度な使用は避け、実際にパフォーマンス問題がある場合のみ使用することが推奨されます。

useCallback(メモ化された関数)

useCallbackは、関数をメモ化します。依存配列の値が変更された場合のみ新しい関数を作成します。React.memoでラップされた子コンポーネントにpropsとして関数を渡す場合に特に有効です。これにより、不要な再レンダリングを防げます。

useMemoとuseCallbackの実装例

この例では、useMemoとuseCallbackの使用方法を示しています。useMemoは計算結果をメモ化し、useCallbackは関数をメモ化します。

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

// useMemoの例:重い計算をメモ化
function ExpensiveComponent({ items }) {
    const [filter, setFilter] = useState('');
    
    // フィルタリングとソートの結果をメモ化
    const filteredAndSortedItems = useMemo(() => {
        console.log('計算中...');
        return items
            .filter(item => item.name.includes(filter))
            .sort((a, b) => a.name.localeCompare(b.name));
    }, [items, filter]);
    
    return (
        <div>
            <input
                value={filter}
                onChange={(e) => setFilter(e.target.value)}
                placeholder="フィルタ"
            />
            <ul>
                {filteredAndSortedItems.map(item => (
                    <li key={item.id}>{item.name}</li>
                ))}
            </ul>
        </div>
    );
}

// useCallbackの例:関数をメモ化
const ChildComponent = memo(({ onClick, name }) => {
    console.log(`${name}がレンダリングされました`);
    return (
        <button onClick={onClick}>
            {name}をクリック
        </button>
    );
});

function ParentComponent() {
    const [count, setCount] = useState(0);
    const [name, setName] = useState('子コンポーネント');
    
    // 関数をメモ化(依存配列が空なので、常に同じ関数参照)
    const handleClick = useCallback(() => {
        console.log('クリックされました');
    }, []);
    
    // 依存配列にnameを含めると、nameが変更された場合のみ新しい関数が作成される
    const handleClickWithName = useCallback(() => {
        console.log(`${name}がクリックされました`);
    }, [name]);
    
    return (
        <div>
            <p>カウント: {count}</p>
            <button onClick={() => setCount(count + 1)}>
                カウントを増やす
            </button>
            <input
                value={name}
                onChange={(e) => setName(e.target.value)}
            />
            <ChildComponent onClick={handleClick} name="コンポーネント1" />
            <ChildComponent onClick={handleClickWithName} name="コンポーネント2" />
        </div>
    );
}

// 実践的な例:リストのフィルタリング
function ProductList({ products }) {
    const [filter, setFilter] = useState('');
    const [sortBy, setSortBy] = useState('name');
    
    // フィルタリングとソートの結果をメモ化
    const filteredProducts = useMemo(() => {
        let result = products.filter(product =>
            product.name.toLowerCase().includes(filter.toLowerCase())
        );
        
        result.sort((a, b) => {
            if (sortBy === 'name') {
                return a.name.localeCompare(b.name);
            } else if (sortBy === 'price') {
                return a.price - b.price;
            }
            return 0;
        });
        
        return result;
    }, [products, filter, sortBy]);
    
    return (
        <div>
            <input
                value={filter}
                onChange={(e) => setFilter(e.target.value)}
                placeholder="検索"
            />
            <select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
                <option value="name">名前順</option>
                <option value="price">価格順</option>
            </select>
            <ul>
                {filteredProducts.map(product => (
                    <li key={product.id}>
                        {product.name} - {product.price}円
                    </li>
                ))}
            </ul>
        </div>
    );
}

7. カスタムフック

カスタムフックは、ロジックを再利用可能な関数に抽出するための仕組みです。useで始まる関数名で定義し、他のフックを使用できます。

APIデータ取得のカスタムフック

APIデータ取得のロジックをカスタムフックに抽出することで、複数のコンポーネントで再利用できます。ローディング状態、エラー処理、データの取得を1つのフックに集約できます。

ローカルストレージのカスタムフック

ローカルストレージへの保存と読み込みをカスタムフックに抽出することで、状態管理と永続化を統合できます。状態が変更されるたびに自動的にローカルストレージに保存され、ページをリロードしても状態が保持されます。

カスタムフックのメリット

  • ロジックの再利用: 同じロジックを複数のコンポーネントで使用できます。
  • コードの整理: コンポーネントからロジックを分離することで、コンポーネントがシンプルになります。
  • テストの容易さ: カスタムフックを独立してテストできます。

カスタムフックの実装例

この例では、APIデータ取得、ローカルストレージ、ウィンドウサイズ追跡のカスタムフックの実装を示しています。

import { useState, useEffect } from 'react';

// APIデータ取得のカスタムフック
function useFetch(url) {
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);
    
    useEffect(() => {
        let cancelled = false;
        
        async function fetchData() {
            try {
                setLoading(true);
                setError(null);
                const response = await fetch(url);
                
                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }
                
                const result = await response.json();
                
                if (!cancelled) {
                    setData(result);
                    setLoading(false);
                }
            } catch (err) {
                if (!cancelled) {
                    setError(err.message);
                    setLoading(false);
                }
            }
        }
        
        fetchData();
        
        // クリーンアップ関数
        return () => {
            cancelled = true;
        };
    }, [url]);
    
    return { data, loading, error };
}

// 使用例
function UserProfile({ userId }) {
    const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
    
    if (loading) return <div>読み込み中...</div>;
    if (error) return <div>エラー: {error}</div>;
    if (!user) return <div>ユーザーが見つかりません</div>;
    
    return <div>{user.name}</div>;
}

// ローカルストレージのカスタムフック
function useLocalStorage(key, initialValue) {
    // 初期値をローカルストレージから読み込む
    const [storedValue, setStoredValue] = useState(() => {
        try {
            const item = window.localStorage.getItem(key);
            return item ? JSON.parse(item) : initialValue;
        } catch (error) {
            console.error(error);
            return initialValue;
        }
    });
    
    // 値を設定する関数
    const setValue = (value) => {
        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];
}

// 使用例
function Settings() {
    const [theme, setTheme] = useLocalStorage('theme', 'light');
    const [language, setLanguage] = useLocalStorage('language', 'ja');
    
    return (
        <div>
            <select value={theme} onChange={(e) => setTheme(e.target.value)}>
                <option value="light">ライト</option>
                <option value="dark">ダーク</option>
            </select>
            <select value={language} onChange={(e) => setLanguage(e.target.value)}>
                <option value="ja">日本語</option>
                <option value="en">English</option>
            </select>
        </div>
    );
}

// ウィンドウサイズを追跡するカスタムフック
function useWindowSize() {
    const [windowSize, setWindowSize] = useState({
        width: typeof window !== 'undefined' ? window.innerWidth : 0,
        height: typeof window !== 'undefined' ? window.innerHeight : 0
    });
    
    useEffect(() => {
        function handleResize() {
            setWindowSize({
                width: window.innerWidth,
                height: window.innerHeight
            });
        }
        
        window.addEventListener('resize', handleResize);
        handleResize(); // 初期値を設定
        
        return () => window.removeEventListener('resize', handleResize);
    }, []);
    
    return windowSize;
}

// 使用例
function ResponsiveComponent() {
    const { width } = useWindowSize();
    
    return (
        <div>
            {width < 768 ? (
                <div>モバイル表示</div>
            ) : (
                <div>デスクトップ表示</div>
            )}
        </div>
    );
}

8. ベストプラクティス

React Hooksを効果的に使用するには、適切なベストプラクティスに従うことが重要です。Hooksのルールを守り、パフォーマンスを考慮し、適切にクリーンアップを行うことで、保守しやすいコードを書けます。

ベストプラクティス

  • Hooksのルールを守る: Hooksはトップレベルでのみ呼び出し、条件分岐やループ内では呼び出さないようにします。ESLintプラグインを使用してルール違反を検出できます。
  • 適切な依存配列の設定: useEffectやuseMemo、useCallbackの依存配列に、使用しているすべての値を含めます。これにより、予期しない動作を防げます。
  • クリーンアップ処理の実装: useEffect内でタイマー、イベントリスナー、サブスクリプションなどを設定した場合、必ずクリーンアップ関数で削除します。
  • カスタムフックの活用: 再利用可能なロジックはカスタムフックに抽出します。これにより、コードの重複を減らし、テストしやすくなります。
  • useMemoとuseCallbackの適切な使用: パフォーマンス最適化が必要な場合のみuseMemoとuseCallbackを使用します。過度な使用は避け、実際にパフォーマンス問題がある場合のみ使用します。
  • 状態の構造化: 関連する状態は、オブジェクトやuseReducerを使用してまとめます。これにより、状態の管理が容易になります。
  • エラーハンドリング: useEffect内での非同期処理には、適切なエラーハンドリングを実装します。try-catchブロックやエラー状態の管理を行います。

よくある間違い

  • 条件分岐内でのHooks呼び出し: Hooksを条件分岐内で呼び出すと、Hooksの呼び出し順序が変わってしまい、エラーが発生します。
  • 依存配列の不備: 依存配列に必要な値を含めないと、古い値が参照されたり、無限ループが発生したりする可能性があります。
  • クリーンアップ処理の欠如: タイマーやイベントリスナーをクリーンアップしないと、メモリリークが発生する可能性があります。
  • 過度な最適化: useMemoやuseCallbackを過度に使用すると、コードが複雑になり、逆にパフォーマンスが低下する可能性があります。

まとめ

React Hooksは、関数コンポーネントで状態管理やライフサイクル機能を使用できるようにする強力な機能です。useStateで状態を管理し、useEffectで副作用を処理し、カスタムフックでロジックを再利用できます。

Hooksのルールを守り、適切にクリーンアップを行うことで、メモリリークを防ぎ、パフォーマンスを最適化できます。カスタムフックを活用することで、コードの再利用性と保守性が向上します。

実践的なプロジェクトでReact Hooksを使用し、経験を積むことで、より効率的で保守しやすいReactアプリケーションを構築できるようになります。