ReactのuseEffectとは?副作用を理解する基本の考え方

ReactのuseEffectフックについて副作用の概念から実践的な使い方まで初心者向けに解説。依存配列、クリーンアップ、実際のコード例とともに詳しく説明します。

Learning Next 運営
51 分で読めます

ReactのuseEffectで悩んだことはありませんか?

「useEffectって何をするためのもの?」 「副作用って何のこと?」 「いつuseEffectを使えばいいの?」

このような疑問を抱えている方も多いですよね。

実は、useEffectの基本概念を理解すれば、API呼び出しやDOM操作などの複雑な処理も簡単に扱えるようになるんです。

この記事では、ReactのuseEffectフックについて副作用の基本概念から実践的な使い方まで詳しく解説します。 依存配列やクリーンアップ処理、よくあるパターンまで、実際のコード例とともに理解していきましょう。

useEffectって何?基本概念を理解しよう

useEffectは、React関数コンポーネントで**副作用(Side Effects)**を扱うためのフックです。

副作用(Side Effects)って何?

プログラミングにおける副作用とは、関数の主な目的以外に発生する処理のことです。

// 純粋な関数(副作用なし)
function add(a, b) {
    return a + b; // 引数を受け取り、結果を返すだけ
}

// 副作用のある関数
function greetUser(name) {
    console.log(`こんにちは、${name}さん!`); // コンソール出力(副作用)
    return `Hello, ${name}!`;
}

function fetchUserData(userId) {
    // API呼び出し(副作用)
    fetch(`/api/users/${userId}`)
        .then(response => response.json())
        .then(data => console.log(data));
}

純粋な関数は引数を受け取って結果を返すだけです。 副作用のある関数は、それ以外の処理(コンソール出力やAPI呼び出し)も行います。

Reactにおける副作用の例には以下があります。

  • API呼び出し
  • DOM操作
  • タイマーの設定
  • イベントリスナーの追加
  • ローカルストレージへのアクセス

これらは、コンポーネントのレンダリング以外の処理ですね。

useEffectの基本構文

import React, { useEffect } from 'react';

function MyComponent() {
    useEffect(() => {
        // 副作用処理をここに書く
        console.log('コンポーネントがレンダリングされました');
    });

    return <div>Hello, World!</div>;
}

useEffectは、コンポーネントがレンダリングされた後に実行されます。 とってもシンプルですよね。

クラスコンポーネントとの比較

従来のクラスコンポーネントと比べてみましょう。

// クラスコンポーネント(従来の方法)
class MyClassComponent extends React.Component {
    componentDidMount() {
        // マウント時の処理
        console.log('コンポーネントがマウントされました');
    }
    
    componentDidUpdate() {
        // 更新時の処理
        console.log('コンポーネントが更新されました');
    }
    
    componentWillUnmount() {
        // アンマウント時の処理
        console.log('コンポーネントがアンマウントされます');
    }
    
    render() {
        return <div>Hello, World!</div>;
    }
}

// 関数コンポーネント + useEffect(新しい方法)
function MyFunctionComponent() {
    useEffect(() => {
        // マウント時と更新時の処理
        console.log('コンポーネントがレンダリングされました');
        
        // クリーンアップ関数(アンマウント時の処理)
        return () => {
            console.log('クリーンアップ処理');
        };
    });
    
    return <div>Hello, World!</div>;
}

useEffectを使うことで、複数のライフサイクルメソッドを1つの関数で管理できます。 ずいぶんスッキリしますね!

useEffectの実行タイミング

useEffectの実行タイミングを依存配列で制御する方法を学びましょう。

1. 依存配列なし(毎回実行)

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

function EveryRenderExample() {
    const [count, setCount] = useState(0);
    
    // 依存配列を指定しない場合、毎回実行される
    useEffect(() => {
        console.log('useEffectが実行されました', count);
        document.title = `カウント: ${count}`;
    });
    
    return (
        <div>
            <p>カウント: {count}</p>
            <button onClick={() => setCount(count + 1)}>
                増加
            </button>
        </div>
    );
}

この例では、ボタンをクリックしてcountが変わるたびにuseEffectが実行されます。 document.titleでブラウザのタブのタイトルも更新されるんです。

2. 空の依存配列(初回のみ実行)

function MountOnlyExample() {
    const [data, setData] = useState(null);
    
    // 空の依存配列:初回レンダリング時のみ実行
    useEffect(() => {
        console.log('初回レンダリング時のみ実行');
        
        // API呼び出しなど、一度だけ実行したい処理
        fetch('/api/initial-data')
            .then(response => response.json())
            .then(result => setData(result))
            .catch(error => console.error('データ取得エラー:', error));
    }, []); // 空の依存配列
    
    return (
        <div>
            {data ? (
                <p>データ: {JSON.stringify(data)}</p>
            ) : (
                <p>読み込み中...</p>
            )}
        </div>
    );
}

空の依存配列[]を指定すると、初回レンダリング時のみ実行されます。 API呼び出しなど、一度だけ実行したい処理にぴったりです。

3. 特定の値に依存(値が変わったときのみ実行)

function DependencyExample() {
    const [userId, setUserId] = useState(1);
    const [userData, setUserData] = useState(null);
    const [loading, setLoading] = useState(false);
    
    // userIdが変わったときのみ実行
    useEffect(() => {
        console.log('userIdが変更されました:', userId);
        
        setLoading(true);
        setUserData(null);
        
        fetch(`/api/users/${userId}`)
            .then(response => response.json())
            .then(data => {
                setUserData(data);
                setLoading(false);
            })
            .catch(error => {
                console.error('ユーザーデータ取得エラー:', error);
                setLoading(false);
            });
    }, [userId]); // userIdが変わったときのみ実行
    
    return (
        <div>
            <div>
                <button onClick={() => setUserId(1)}>ユーザー1</button>
                <button onClick={() => setUserId(2)}>ユーザー2</button>
                <button onClick={() => setUserId(3)}>ユーザー3</button>
            </div>
            
            {loading ? (
                <p>読み込み中...</p>
            ) : userData ? (
                <div>
                    <h3>{userData.name}</h3>
                    <p>メール: {userData.email}</p>
                    <p>職業: {userData.job}</p>
                </div>
            ) : (
                <p>ユーザーデータがありません</p>
            )}
        </div>
    );
}

[userId]のように特定の値を依存配列に指定すると、その値が変わったときのみ実行されます。 ボタンでユーザーを切り替えると、新しいユーザーデータが取得されます。

4. 複数の依存関係

function MultiDependencyExample() {
    const [searchTerm, setSearchTerm] = useState('');
    const [category, setCategory] = useState('all');
    const [results, setResults] = useState([]);
    
    // searchTermまたはcategoryが変わったときに実行
    useEffect(() => {
        console.log('検索条件が変更されました:', { searchTerm, category });
        
        // 検索処理
        if (searchTerm.trim() || category !== 'all') {
            const searchParams = new URLSearchParams({
                term: searchTerm,
                category: category
            });
            
            fetch(`/api/search?${searchParams}`)
                .then(response => response.json())
                .then(data => setResults(data))
                .catch(error => console.error('検索エラー:', error));
        } else {
            setResults([]);
        }
    }, [searchTerm, category]); // 両方の値が依存関係
    
    return (
        <div>
            <input
                type="text"
                value={searchTerm}
                onChange={(e) => setSearchTerm(e.target.value)}
                placeholder="検索キーワード"
            />
            
            <select value={category} onChange={(e) => setCategory(e.target.value)}>
                <option value="all">すべてのカテゴリ</option>
                <option value="books">本</option>
                <option value="movies">映画</option>
                <option value="music">音楽</option>
            </select>
            
            <div>
                <h3>検索結果 ({results.length}件)</h3>
                {results.map((item, index) => (
                    <div key={index}>
                        <h4>{item.title}</h4>
                        <p>{item.description}</p>
                    </div>
                ))}
            </div>
        </div>
    );
}

複数の値を依存配列に含めることで、どちらかが変わったときに実行されます。 検索キーワードやカテゴリが変わると、自動的に検索が実行されるんです。

クリーンアップ処理の重要性

useEffectでは、クリーンアップ処理を適切に行うことが重要です。

基本的なクリーンアップ

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

function TimerComponent() {
    const [seconds, setSeconds] = useState(0);
    const [isRunning, setIsRunning] = useState(false);
    
    useEffect(() => {
        let interval = null;
        
        if (isRunning) {
            interval = setInterval(() => {
                setSeconds(prevSeconds => prevSeconds + 1);
            }, 1000);
        }
        
        // クリーンアップ関数
        return () => {
            if (interval) {
                console.log('タイマーをクリアします');
                clearInterval(interval);
            }
        };
    }, [isRunning]); // isRunningが変わったときに実行
    
    const handleStart = () => setIsRunning(true);
    const handleStop = () => setIsRunning(false);
    const handleReset = () => {
        setSeconds(0);
        setIsRunning(false);
    };
    
    return (
        <div>
            <h2>タイマー: {seconds}秒</h2>
            <button onClick={handleStart} disabled={isRunning}>
                開始
            </button>
            <button onClick={handleStop} disabled={!isRunning}>
                停止
            </button>
            <button onClick={handleReset}>
                リセット
            </button>
        </div>
    );
}

クリーンアップ関数でタイマーを削除することで、メモリリークを防げます。 returnで関数を返すのがポイントです。

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

function WindowSizeTracker() {
    const [windowSize, setWindowSize] = useState({
        width: window.innerWidth,
        height: window.innerHeight
    });
    
    useEffect(() => {
        console.log('ウィンドウサイズ監視を開始');
        
        const handleResize = () => {
            setWindowSize({
                width: window.innerWidth,
                height: window.innerHeight
            });
        };
        
        // イベントリスナーを追加
        window.addEventListener('resize', handleResize);
        
        // クリーンアップ関数でイベントリスナーを削除
        return () => {
            console.log('ウィンドウサイズ監視を終了');
            window.removeEventListener('resize', handleResize);
        };
    }, []); // マウント時のみ実行
    
    return (
        <div>
            <h2>ウィンドウサイズ</h2>
            <p>幅: {windowSize.width}px</p>
            <p>高さ: {windowSize.height}px</p>
        </div>
    );
}

イベントリスナーも適切にクリーンアップしないと、メモリリークの原因になります。 ウィンドウをリサイズすると、リアルタイムでサイズが更新されます。

API呼び出しのキャンセル

function UserProfile({ userId }) {
    const [user, setUser] = useState(null);
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState(null);
    
    useEffect(() => {
        console.log('ユーザーデータを取得中:', userId);
        
        const abortController = new AbortController();
        
        setLoading(true);
        setError(null);
        setUser(null);
        
        fetch(`/api/users/${userId}`, {
            signal: abortController.signal
        })
            .then(response => {
                if (!response.ok) {
                    throw new Error('ユーザーが見つかりません');
                }
                return response.json();
            })
            .then(userData => {
                setUser(userData);
                setLoading(false);
            })
            .catch(error => {
                if (error.name !== 'AbortError') {
                    console.error('ユーザーデータ取得エラー:', error);
                    setError(error.message);
                    setLoading(false);
                }
            });
        
        // クリーンアップ関数でAPI呼び出しをキャンセル
        return () => {
            console.log('API呼び出しをキャンセル');
            abortController.abort();
        };
    }, [userId]);
    
    if (loading) return <div>読み込み中...</div>;
    if (error) return <div>エラー: {error}</div>;
    if (!user) return <div>ユーザーが選択されていません</div>;
    
    return (
        <div>
            <h2>{user.name}</h2>
            <p>メール: {user.email}</p>
            <p>登録日: {new Date(user.createdAt).toLocaleDateString()}</p>
        </div>
    );
}

AbortControllerを使ってAPI呼び出しをキャンセルできます。 ユーザーIDが変わったときに、前のAPI呼び出しが自動的にキャンセルされるんです。

実践的なuseEffect活用例

実際の開発でよく使われるuseEffectのパターンを紹介します。

1. データフェッチング

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

function ProductList() {
    const [products, setProducts] = useState([]);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);
    
    useEffect(() => {
        const fetchProducts = async () => {
            try {
                setLoading(true);
                const response = await fetch('/api/products');
                
                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }
                
                const data = await response.json();
                setProducts(data);
            } catch (err) {
                console.error('商品データ取得エラー:', err);
                setError(err.message);
            } finally {
                setLoading(false);
            }
        };
        
        fetchProducts();
    }, []); // マウント時のみ実行
    
    if (loading) return <div className="loading">読み込み中...</div>;
    if (error) return <div className="error">エラー: {error}</div>;
    
    return (
        <div className="product-list">
            <h1>商品一覧</h1>
            <div className="products-grid">
                {products.map(product => (
                    <div key={product.id} className="product-card">
                        <img src={product.image} alt={product.name} />
                        <h3>{product.name}</h3>
                        <p className="price">¥{product.price.toLocaleString()}</p>
                        <p className="description">{product.description}</p>
                    </div>
                ))}
            </div>
        </div>
    );
}

async/awaitを使った非同期処理の典型的なパターンです。 try-catch-finallyでエラーハンドリングも丁寧に行っています。

商品データを取得して、読み込み状態とエラー状態も管理しています。 実際のECサイトでもこのようなパターンがよく使われるんです。

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

function TodoApp() {
    const [todos, setTodos] = useState([]);
    const [inputValue, setInputValue] = useState('');
    
    // ローカルストレージから初期データを読み込み
    useEffect(() => {
        console.log('ローカルストレージから Todo を読み込み');
        const savedTodos = localStorage.getItem('todos');
        if (savedTodos) {
            try {
                const parsedTodos = JSON.parse(savedTodos);
                setTodos(parsedTodos);
            } catch (error) {
                console.error('Todo データの解析に失敗:', error);
            }
        }
    }, []); // マウント時のみ実行
    
    // todos が変更されるたびにローカルストレージに保存
    useEffect(() => {
        console.log('ローカルストレージに Todo を保存');
        localStorage.setItem('todos', JSON.stringify(todos));
    }, [todos]); // todos が変更されたときのみ実行
    
    const addTodo = () => {
        if (inputValue.trim()) {
            const newTodo = {
                id: Date.now(),
                text: inputValue,
                completed: false,
                createdAt: new Date().toISOString()
            };
            setTodos(prevTodos => [...prevTodos, newTodo]);
            setInputValue('');
        }
    };
    
    const toggleTodo = (id) => {
        setTodos(prevTodos =>
            prevTodos.map(todo =>
                todo.id === id ? { ...todo, completed: !todo.completed } : todo
            )
        );
    };
    
    const deleteTodo = (id) => {
        setTodos(prevTodos => prevTodos.filter(todo => todo.id !== id));
    };
    
    return (
        <div className="todo-app">
            <h1>Todo アプリ</h1>
            
            <div className="input-section">
                <input
                    type="text"
                    value={inputValue}
                    onChange={(e) => setInputValue(e.target.value)}
                    onKeyPress={(e) => e.key === 'Enter' && addTodo()}
                    placeholder="新しいタスクを入力"
                />
                <button onClick={addTodo}>追加</button>
            </div>
            
            <ul className="todo-list">
                {todos.map(todo => (
                    <li key={todo.id} className={todo.completed ? 'completed' : ''}>
                        <input
                            type="checkbox"
                            checked={todo.completed}
                            onChange={() => toggleTodo(todo.id)}
                        />
                        <span>{todo.text}</span>
                        <button onClick={() => deleteTodo(todo.id)}>削除</button>
                    </li>
                ))}
            </ul>
            
            {todos.length === 0 && (
                <p className="empty-message">タスクがありません</p>
            )}
        </div>
    );
}

このコードでは、2つのuseEffectを使い分けています。

最初のuseEffectは初期データの読み込み用です。 空の依存配列[]で、マウント時のみ実行されます。

2つ目のuseEffectはデータの保存用です。 [todos]を依存配列にして、todosが変更されるたびに自動保存されます。

3. リアルタイム通信

function ChatComponent({ roomId, currentUser }) {
    const [messages, setMessages] = useState([]);
    const [connected, setConnected] = useState(false);
    const [socket, setSocket] = useState(null);
    
    useEffect(() => {
        console.log('WebSocket接続を開始:', roomId);
        
        // WebSocket接続
        const ws = new WebSocket(`ws://localhost:8080/chat/${roomId}`);
        
        ws.onopen = () => {
            console.log('WebSocket接続が確立されました');
            setConnected(true);
            setSocket(ws);
        };
        
        ws.onmessage = (event) => {
            try {
                const message = JSON.parse(event.data);
                setMessages(prevMessages => [...prevMessages, message]);
            } catch (error) {
                console.error('メッセージの解析に失敗:', error);
            }
        };
        
        ws.onclose = () => {
            console.log('WebSocket接続が切断されました');
            setConnected(false);
            setSocket(null);
        };
        
        ws.onerror = (error) => {
            console.error('WebSocketエラー:', error);
        };
        
        // クリーンアップ関数
        return () => {
            console.log('WebSocket接続をクリーンアップ');
            if (ws.readyState === WebSocket.OPEN) {
                ws.close();
            }
        };
    }, [roomId]); // roomIdが変わったときに再接続
    
    const sendMessage = (messageText) => {
        if (socket && connected && messageText.trim()) {
            const message = {
                id: Date.now(),
                text: messageText,
                user: currentUser,
                timestamp: new Date().toISOString()
            };
            
            socket.send(JSON.stringify(message));
        }
    };
    
    return (
        <div className="chat-component">
            <div className="connection-status">
                状態: {connected ? '接続中' : '切断中'}
            </div>
            
            <div className="messages">
                {messages.map(message => (
                    <div key={message.id} className="message">
                        <strong>{message.user}:</strong> {message.text}
                        <small>{new Date(message.timestamp).toLocaleTimeString()}</small>
                    </div>
                ))}
            </div>
            
            <ChatInput onSendMessage={sendMessage} disabled={!connected} />
        </div>
    );
}

function ChatInput({ onSendMessage, disabled }) {
    const [inputValue, setInputValue] = useState('');
    
    const handleSubmit = (e) => {
        e.preventDefault();
        if (inputValue.trim()) {
            onSendMessage(inputValue);
            setInputValue('');
        }
    };
    
    return (
        <form onSubmit={handleSubmit} className="chat-input">
            <input
                type="text"
                value={inputValue}
                onChange={(e) => setInputValue(e.target.value)}
                placeholder="メッセージを入力..."
                disabled={disabled}
            />
            <button type="submit" disabled={disabled || !inputValue.trim()}>
                送信
            </button>
        </form>
    );
}

WebSocketを使ったリアルタイム通信の例です。

WebSocket接続を行い、メッセージを受信したら自動的に画面に表示されます。 roomIdが変わったときは、前の接続を切断して新しい部屋に接続します。

クリーンアップ関数でWebSocket接続を適切に切断することで、リソースの無駄遣いを防いでいます。

4. フォームバリデーション

function ContactForm() {
    const [formData, setFormData] = useState({
        name: '',
        email: '',
        message: ''
    });
    const [errors, setErrors] = useState({});
    const [isValid, setIsValid] = useState(false);
    
    // フォームデータが変更されるたびにバリデーション実行
    useEffect(() => {
        const validateForm = () => {
            const newErrors = {};
            
            // 名前のバリデーション
            if (!formData.name.trim()) {
                newErrors.name = '名前は必須です';
            } else if (formData.name.trim().length < 2) {
                newErrors.name = '名前は2文字以上で入力してください';
            }
            
            // メールのバリデーション
            if (!formData.email.trim()) {
                newErrors.email = 'メールアドレスは必須です';
            } else if (!/\S+@\S+\.\S+/.test(formData.email)) {
                newErrors.email = 'メールアドレスの形式が正しくありません';
            }
            
            // メッセージのバリデーション
            if (!formData.message.trim()) {
                newErrors.message = 'メッセージは必須です';
            } else if (formData.message.trim().length < 10) {
                newErrors.message = 'メッセージは10文字以上で入力してください';
            }
            
            setErrors(newErrors);
            setIsValid(Object.keys(newErrors).length === 0);
        };
        
        validateForm();
    }, [formData]); // formDataが変更されたときにバリデーション
    
    const handleChange = (e) => {
        const { name, value } = e.target;
        setFormData(prevData => ({
            ...prevData,
            [name]: value
        }));
    };
    
    const handleSubmit = async (e) => {
        e.preventDefault();
        
        if (!isValid) {
            alert('フォームに入力エラーがあります');
            return;
        }
        
        try {
            const response = await fetch('/api/contact', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify(formData)
            });
            
            if (response.ok) {
                alert('送信が完了しました');
                setFormData({ name: '', email: '', message: '' });
            } else {
                throw new Error('送信に失敗しました');
            }
        } catch (error) {
            console.error('送信エラー:', error);
            alert('送信に失敗しました');
        }
    };
    
    return (
        <form onSubmit={handleSubmit} className="contact-form">
            <h2>お問い合わせ</h2>
            
            <div className="form-group">
                <label htmlFor="name">名前</label>
                <input
                    type="text"
                    id="name"
                    name="name"
                    value={formData.name}
                    onChange={handleChange}
                    className={errors.name ? 'error' : ''}
                />
                {errors.name && <span className="error-message">{errors.name}</span>}
            </div>
            
            <div className="form-group">
                <label htmlFor="email">メールアドレス</label>
                <input
                    type="email"
                    id="email"
                    name="email"
                    value={formData.email}
                    onChange={handleChange}
                    className={errors.email ? 'error' : ''}
                />
                {errors.email && <span className="error-message">{errors.email}</span>}
            </div>
            
            <div className="form-group">
                <label htmlFor="message">メッセージ</label>
                <textarea
                    id="message"
                    name="message"
                    value={formData.message}
                    onChange={handleChange}
                    rows="5"
                    className={errors.message ? 'error' : ''}
                />
                {errors.message && <span className="error-message">{errors.message}</span>}
            </div>
            
            <button type="submit" disabled={!isValid}>
                送信
            </button>
        </form>
    );
}

フォームデータが変更されるたびにリアルタイムバリデーションを実行します。

validateForm関数で各フィールドをチェックし、エラーメッセージを設定します。 エラーがなければ送信ボタンが有効になる仕組みです。

よくある間違いと対処法

useEffectを使う際によくある間違いとその解決方法を紹介します。

1. 無限ループの発生

// ❌ 間違い:無限ループが発生
function BadExample() {
    const [data, setData] = useState([]);
    
    useEffect(() => {
        // dataが変更されるたびにuseEffectが実行される
        setData([...data, 'new item']); // 無限ループの原因
    }, [data]); // dataを依存配列に含めている
    
    return <div>{data.length}</div>;
}

// ✅ 正しい実装
function GoodExample() {
    const [data, setData] = useState([]);
    
    useEffect(() => {
        // 初回のみ実行
        const initialData = ['item1', 'item2', 'item3'];
        setData(initialData);
    }, []); // 空の依存配列
    
    const addItem = () => {
        setData(prevData => [...prevData, `item${prevData.length + 1}`]);
    };
    
    return (
        <div>
            <p>アイテム数: {data.length}</p>
            <button onClick={addItem}>アイテム追加</button>
        </div>
    );
}

データを更新するuseEffectの依存配列に、そのデータ自体を含めてはいけません。 無限ループの原因になります。

2. 依存配列の指定ミス

// ❌ 間違い:依存関係が不完全
function BadDependencyExample({ userId, userType }) {
    const [user, setUser] = useState(null);
    
    useEffect(() => {
        fetchUser(userId, userType).then(setUser);
    }, [userId]); // userTypeが変更されても実行されない
    
    return <div>{user?.name}</div>;
}

// ✅ 正しい:すべての依存関係を指定
function GoodDependencyExample({ userId, userType }) {
    const [user, setUser] = useState(null);
    
    useEffect(() => {
        fetchUser(userId, userType).then(setUser);
    }, [userId, userType]); // 両方の値が依存関係
    
    return <div>{user?.name}</div>;
}

useEffect内で使用するすべての変数を依存配列に含める必要があります。 見落としがちなポイントです。

3. クリーンアップの忘れ

// ❌ 間違い:クリーンアップしない
function BadCleanupExample() {
    const [count, setCount] = useState(0);
    
    useEffect(() => {
        const interval = setInterval(() => {
            setCount(prev => prev + 1);
        }, 1000);
        
        // クリーンアップを忘れている
        // コンポーネントがアンマウントされてもタイマーが動き続ける
    }, []);
    
    return <div>{count}</div>;
}

// ✅ 正しい:適切なクリーンアップ
function GoodCleanupExample() {
    const [count, setCount] = useState(0);
    
    useEffect(() => {
        const interval = setInterval(() => {
            setCount(prev => prev + 1);
        }, 1000);
        
        // クリーンアップ関数でタイマーを削除
        return () => {
            clearInterval(interval);
        };
    }, []);
    
    return <div>{count}</div>;
}

タイマーやイベントリスナーは必ずクリーンアップしましょう。 メモリリークの原因になります。

4. 非同期処理の間違った書き方

// ❌ 間違い:useEffectを直接asyncにする
function BadAsyncExample() {
    const [data, setData] = useState(null);
    
    // useEffectの関数を直接asyncにしてはいけない
    useEffect(async () => {
        const response = await fetch('/api/data');
        const result = await response.json();
        setData(result);
    }, []);
    
    return <div>{data}</div>;
}

// ✅ 正しい:useEffect内で非同期関数を定義
function GoodAsyncExample() {
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState(null);
    
    useEffect(() => {
        const fetchData = async () => {
            try {
                setLoading(true);
                setError(null);
                
                const response = await fetch('/api/data');
                if (!response.ok) {
                    throw new Error('データの取得に失敗しました');
                }
                
                const result = await response.json();
                setData(result);
            } catch (err) {
                setError(err.message);
            } finally {
                setLoading(false);
            }
        };
        
        fetchData();
    }, []);
    
    if (loading) return <div>読み込み中...</div>;
    if (error) return <div>エラー: {error}</div>;
    
    return <div>{JSON.stringify(data)}</div>;
}

useEffectの関数を直接asyncにしてはいけません。 useEffect内で非同期関数を定義して呼び出すのが正しい方法です。

useEffectのベストプラクティス

効果的なuseEffectの使い方をまとめます。

1. 単一責任の原則

// ❌ 間違い:1つのuseEffectで複数の関心事を扱う
function BadPractice() {
    const [user, setUser] = useState(null);
    const [posts, setPosts] = useState([]);
    const [notifications, setNotifications] = useState([]);
    
    useEffect(() => {
        // 複数の異なる処理を1つのuseEffectで行っている
        fetchUser().then(setUser);
        fetchPosts().then(setPosts);
        fetchNotifications().then(setNotifications);
        
        const interval = setInterval(() => {
            checkNewNotifications();
        }, 30000);
        
        return () => clearInterval(interval);
    }, []);
}

// ✅ 正しい:関心事ごとにuseEffectを分離
function GoodPractice() {
    const [user, setUser] = useState(null);
    const [posts, setPosts] = useState([]);
    const [notifications, setNotifications] = useState([]);
    
    // ユーザー情報の取得
    useEffect(() => {
        fetchUser().then(setUser);
    }, []);
    
    // 投稿の取得
    useEffect(() => {
        fetchPosts().then(setPosts);
    }, []);
    
    // 通知の取得と定期更新
    useEffect(() => {
        fetchNotifications().then(setNotifications);
        
        const interval = setInterval(() => {
            checkNewNotifications().then(setNotifications);
        }, 30000);
        
        return () => clearInterval(interval);
    }, []);
}

1つのuseEffectに複数の処理を詰め込まず、関心事ごとに分離しましょう。 コードが読みやすくなり、保守性も向上します。

2. カスタムフックで再利用性を高める

// データフェッチング用のカスタムフック
function useApi(url) {
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState(null);
    
    useEffect(() => {
        if (!url) return;
        
        const abortController = new AbortController();
        
        const fetchData = async () => {
            try {
                setLoading(true);
                setError(null);
                
                const response = await fetch(url, {
                    signal: abortController.signal
                });
                
                if (!response.ok) {
                    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
                }
                
                const result = await response.json();
                setData(result);
            } catch (err) {
                if (err.name !== 'AbortError') {
                    setError(err.message);
                }
            } finally {
                setLoading(false);
            }
        };
        
        fetchData();
        
        return () => {
            abortController.abort();
        };
    }, [url]);
    
    return { data, loading, error };
}

// ローカルストレージ用のカスタムフック
function useLocalStorage(key, initialValue) {
    const [value, setValue] = useState(() => {
        try {
            const item = window.localStorage.getItem(key);
            return item ? JSON.parse(item) : initialValue;
        } catch (error) {
            console.error(`ローカルストレージの読み込みエラー:`, error);
            return initialValue;
        }
    });
    
    useEffect(() => {
        try {
            window.localStorage.setItem(key, JSON.stringify(value));
        } catch (error) {
            console.error(`ローカルストレージの保存エラー:`, error);
        }
    }, [key, value]);
    
    return [value, setValue];
}

// カスタムフックの使用例
function UserProfile({ userId }) {
    const { data: user, loading, error } = useApi(`/api/users/${userId}`);
    const [favorites, setFavorites] = useLocalStorage('user-favorites', []);
    
    if (loading) return <div>読み込み中...</div>;
    if (error) return <div>エラー: {error}</div>;
    if (!user) return <div>ユーザーが見つかりません</div>;
    
    return (
        <div>
            <h1>{user.name}</h1>
            <p>お気に入り数: {favorites.length}</p>
        </div>
    );
}

よく使う処理はカスタムフックとして抽出しましょう。 コードの再利用性が高まり、メンテナンスも楽になります。

まとめ

ReactのuseEffectは、副作用を適切に管理するための重要なフックです。

useEffectを使うことで、API呼び出し、DOM操作、タイマー設定、イベントリスナーの追加など、様々な副作用処理を関数コンポーネントで扱えるようになります。

依存配列を適切に設定することで、実行タイミングを細かく制御できます。 クリーンアップ処理により、メモリリークを防げます。

重要なポイントは以下の通りです。

  • 単一責任の原則に従って関心事ごとにuseEffectを分離する
  • 適切な依存配列とクリーンアップ処理を行う
  • カスタムフックを活用してロジックの再利用性を高める

useEffectを正しく理解して使用することで、より堅牢で保守性の高いReactアプリケーションを開発できるようになります。

ぜひ実際のプロジェクトでuseEffectを活用して、動的で魅力的なユーザーインターフェースを作成してください!

関連記事