React useEffect 実務パターン完全ガイド|実装例で学ぶ業務での正しい使い方

React / Next.js

React useEffect 実務パターン完全ガイド|実装例で学ぶ業務での正しい使い方

React を使った開発をしていると、必ず使うことになるのが useEffect フックです。しかし、教科書的な説明だけでは、実務でどう使うのかがイマイチわからないという経験をしたことはありませんか?

この記事では、実務で本当に必要なパターンを中心に、useEffect の使い方を解説します。単なるサンプルコードではなく、実際のプロジェクトで使えるコードを紹介するので、すぐに業務に応用できます。

useEffect の簡易的な解説

useEffect は、React の関数コンポーネントで副作用(サイドエフェクト)を実行するためのフックです。副作用とは、データの取得、DOM の操作、タイマーの設定、外部ライブラリとの連携など、コンポーネントのレンダリング以外の処理を指します。

基本的な構文は以下の通りです:

useEffect(() => {
  // 副作用の処理
  return () => {
    // クリーンアップ処理(オプション)
  };
}, [依存配列]);

依存配列が重要です。この配列に含まれた値が変わったときだけ、useEffect 内の処理が実行されます。依存配列を省略すると毎回実行され、空配列にするとマウント時のみ実行されます。

実務で本当に使うユースケース

1. API からのデータ取得

最も一般的なユースケースが、コンポーネントマウント時に API からデータを取得することです。実務では、ローディング状態、エラーハンドリング、キャッシュなどを考慮する必要があります。

2. フォーム入力値に応じた検証・フィルタリング

ユーザーが入力した値をリアルタイムで検証したり、別のコンポーネントに同期したりする場合に使います。

3. ページ遷移時のクリーンアップ

タイマーやイベントリスナー、WebSocket の接続をクリーンアップする必要があります。

4. 外部ライブラリの初期化・破棄

Chart.js、Google Maps などのライブラリをセットアップする際に使います。

実務で実際に使うコード

パターン1:API からのデータ取得(キャッシュ付き)

実務では、何度も同じデータを取得しないようにキャッシュを使うことが一般的です。以下は、簡単なメモ化キャッシュを実装した例です:

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

interface User {
  id: number;
  name: string;
  email: string;
}

interface ApiCache {
  [key: string]: User;
}

export const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const cacheRef = useRef({});
  const abortControllerRef = useRef(null);

  useEffect(() => {
    // キャッシュに存在する場合はそれを使用
    if (cacheRef.current[userId]) {
      setUser(cacheRef.current[userId]);
      setLoading(false);
      return;
    }

    // 前のリクエストをキャンセル
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }

    abortControllerRef.current = new AbortController();
    const controller = abortControllerRef.current;

    const fetchUser = async () => {
      try {
        setLoading(true);
        setError(null);

        const response = await fetch(
          `https://api.example.com/users/${userId}`,
          {
            signal: controller.signal,
          }
        );

        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }

        const data: User = await response.json();
        cacheRef.current[userId] = data;
        setUser(data);
      } catch (err) {
        if (err instanceof Error && err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        setLoading(false);
      }
    };

    fetchUser();

    // クリーンアップ:コンポーネント削除時にリクエストをキャンセル
    return () => {
      if (controller) {
        controller.abort();
      }
    };
  }, [userId]);

  if (loading) return 
読み込み中...
; if (error) return
エラー: {error}
; if (!user) return
ユーザーが見つかりません
; return (

{user.name}

{user.email}

); };

このコードのポイント:

  • AbortController を使ってリクエストをキャンセル可能にしている
  • キャッシュを useRef で管理している
  • コンポーネント削除時に不完了のリクエストが残らないようにしている

パターン2:フォーム入力値に応じた検証

フォーム内容をリアルタイムで検証する場合、useEffect を使って効率的に処理できます。実務では、デバウンス処理を入れることが多いです:

import { useEffect, useState } from 'react';

interface FormData {
  email: string;
  username: string;
}

interface ValidationErrors {
  email?: string;
  username?: string;
}

export const RegistrationForm: React.FC = () => {
  const [formData, setFormData] = useState({
    email: '',
    username: '',
  });
  const [errors, setErrors] = useState({});
  const [isValidating, setIsValidating] = useState(false);
  const debounceTimerRef = React.useRef(null);

  // フォーム入力を検証する関数
  const validateForm = async (data: FormData): Promise => {
    const newErrors: ValidationErrors = {};

    // メールアドレスの検証
    const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;
    if (!emailRegex.test(data.email)) {
      newErrors.email = '有効なメールアドレスを入力してください';
    }

    // ユーザー名の検証(サーバー側で確認)
    if (data.username.length < 3) {
      newErrors.username = 'ユーザー名は3文字以上である必要があります';
    } else if (data.username.length > 0) {
      try {
        const response = await fetch(
          `https://api.example.com/check-username?username=${data.username}`
        );
        const isAvailable = await response.json();
        if (!isAvailable) {
          newErrors.username = 'このユーザー名は既に使用されています';
        }
      } catch (err) {
        console.error('ユーザー名の確認に失敗しました', err);
      }
    }

    return newErrors;
  };

  // フォーム値が変わったときの処理
  useEffect(() => {
    // デバウンス処理:ユーザーの入力が落ち着くまで待機
    if (debounceTimerRef.current) {
      clearTimeout(debounceTimerRef.current);
    }

    setIsValidating(true);

    debounceTimerRef.current = setTimeout(async () => {
      const newErrors = await validateForm(formData);
      setErrors(newErrors);
      setIsValidating(false);
    }, 500); // 500ms の遅延

    // クリーンアップ
    return () => {
      if (debounceTimerRef.current) {
        clearTimeout(debounceTimerRef.current);
      }
    };
  }, [formData]);

  const handleChange = (e: React.ChangeEvent) => {
    const { name, value } = e.target;
    setFormData((prev) => ({
      ...prev,
      [name]: value,
    }));
  };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (Object.keys(errors).length === 0) {
      console.log('フォーム送信', formData);
    }
  };

  return (
    
{errors.email && {errors.email}}
{isValidating && 検証中...} {errors.username && {errors.username}}
); };

このコードのポイント:

  • デバウンス処理で無駄なAPI呼び出しを削減
  • useRef を使ってタイマーIDを保持
  • クリーンアップでタイマーをクリア

パターン3:複数の依存値を持つ複雑なシナリオ

実務では、複数の値に応じて異なる処理をする必要があります。以下は、フィルター条件とページネーションを組み合わせた例です:

import { useEffect, useState } from 'react';

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

interface PaginationInfo {
  total: number;
  page: number;
  pageSize: number;
}

export const ProductList: React.FC = () => {
  const [products, setProducts] = useState([]);
  const [filters, setFilters] = useState({
    category: 'all',
    minPrice: 0,
    maxPrice: 1000,
  });
  const [pagination, setPagination] = useState({
    page: 1,
    pageSize: 10,
  });
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    const fetchProducts = async () => {
      try {
        setLoading(true);

        const params = new URLSearchParams({
          category: filters.category,
          minPrice: filters.minPrice.toString(),
          maxPrice: filters.maxPrice.toString(),
          page: pagination.page.toString(),
          pageSize: pagination.pageSize.toString(),
        });

        const response = await fetch(
          `https://api.example.com/products?${params}`
        );
        const data: Product[] = await response.json();

        setProducts(data);
      } catch (err) {
        console.error('商品取得エラー', err);
      } finally {
        setLoading(false);
      }
    };

    fetchProducts();
  }, [filters, pagination.page, pagination.pageSize]);

  const handleFilterChange = (newFilters: typeof filters) => {
    setFilters(newFilters);
    // フィルター変更時はページを1に戻す
    setPagination((prev) => ({ ...prev, page: 1 }));
  };

  return (
    
{/* 他のフィルター */}
{loading &&
読み込み中...
}
{products.map((product) => (

{product.name}

¥{product.price.toLocaleString()}

))}
ページ {pagination.page}
); };

パターン4:クリーンアップが必須の場合

WebSocket やイベントリスナーなど、必ずクリーンアップが必要な場合の実装例です:

import { useEffect, useState } from 'react';

interface ChatMessage {
  id: string;
  user: string;
  content: string;
  timestamp: number;
}

export const ChatComponent: React.FC<{ roomId: string }> = ({ roomId }) => {
  const [messages, setMessages] = useState([]);
  const [connectionStatus, setConnectionStatus] = useState('connecting');

  useEffect(() => {
    // WebSocket 接続
    const ws = new WebSocket(`wss://api.example.com/chat/${roomId}`);

    ws.onopen = () => {
      setConnectionStatus('connected');
      console.log('チャット接続確立');
    };

    ws.onmessage = (event) => {
      try {
        const message: ChatMessage = JSON.parse(event.data);
        setMessages((prev) => [...prev, message]);
      } catch (err) {
        console.error('メッセージパース失敗', err);
      }
    };

    ws.onerror = (error) => {
      setConnectionStatus('error');
      console.error('WebSocket エラー', error);
    };

    ws.onclose = () => {
      setConnectionStatus('disconnected');
      console.log('チャット接続切断');
    };

    // クリーンアップ:コンポーネント削除時に接続を閉じる
    return () => {
      if (ws.readyState === WebSocket.OPEN) {
        ws.close();
      }
    };
  }, [roomId]);

  return (
    
接続状態: {connectionStatus}
{messages.map((msg) => (
{msg.user}: {msg.content}
))}
); };

よくある応用パターン

複数の useEffect を組み合わせる

実務では、異なる責務を持つ複数の useEffect を組み合わせることが多いです。単一の useEffect に複数の処理を詰め込むより、責務ごとに分けるほうが保守性が高くなります:

export const ComplexComponent: React.FC = () => {
  const [data, setData] = useState(null);
  const [filters, setFilters] = useState({});

  // 1つ目の useEffect: データ取得
  useEffect(() => {
    // データ取得ロジック
  }, [filters]);

  // 2つ目の useEffect: 分析イベント送信
  useEffect(() => {
    // Google Analytics などに送信
  }, [data]);

  // 3つ目の useEffect: キーボードショートカット
  useEffect(() => {
    const handleKeyPress = (e: KeyboardEvent) => {
      if (e.ctrlKey && e.key === 's') {
        // 保存処理
      }
    };

    window.addEventListener('keydown', handleKeyPress);

    return () => {
      window.removeEventListener('keydown', handleKeyPress);
    };
  }, []);

  return 
コンポーネント
; };

useCallback と useEffect を組み合わせる

useEffect の依存値に関数を含める場合は、useCallback で関数をメモ化することが重要です:

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

export const OptimizedComponent: React.FC = () => {
  const [data, setData] = useState(null);
  const [filter, setFilter] = useState('');

  // コールバックをメモ化
  const fetchData = useCallback(async () => {
    const response = await fetch(
      `https://api.example.com/data?filter=${filter}`
    );
    const result = await response.json();
    setData(result);
  }, [filter]);

  // メモ化されたコールバックを依存値として使用
  useEffect(() => {
    fetchData();
  }, [fetchData]);

  return 
データ: {JSON.stringify(data)}
; };

実務で気をつけるべき注意点

1. 無限ループの罠

依存配列を忘れると、毎回レンダリング後に useEffect が実行され、無限ループに陥ることがあります:

// ❌ 悪い例:無限ループ
useEffect(() => {
  setCount(count + 1); // 実行されるたびに count が変わり、useEffect が再実行される
});

// ✅ 良い例:初回のみ実行
useEffect(() => {
  // 初期化処理
}, []);

2. 依存配列にオブジェクトを含める場合の注意

オブジェクトや配列は参照が変わるたびに新しいものとして認識されます。必要に応じて useMemo を使いましょう:

import { useMemo } from 'react';

export const ComponentWithObject: React.FC<{
  filters: { category: string; price: number };
}> = ({ filters }) => {
  // ❌ 悪い例:filters は毎回新しいオブジェクトになるため、useEffect が無限実行される
  // useEffect(() => {
  //   console.log(filters);
  // }, [filters]);

  // ✅ 良い例:useMemo でメモ化
  const memoizedFilters = useMemo(
    () => filters,
    [filters.category, filters.price]
  );

  useEffect(() => {
    console.log(memoizedFilters);
  }, [memoizedFilters]);

  return 
フィルター: {JSON.stringify(memoizedFilters)}
; };

3. 非同期処理の実行タイミング

useEffect 内で async 関数を定義する場合は、呼び出しの部分で await するか、別途処理する必要があります:

// ❌ 悪い例:useEffect を async にはできない
// useEffect(async () => { ... }, []);

// ✅ 良い例1:内部で async 関数を定義・実行
useEffect(() => {
  const fetchData = async () => {
    const data = await fetch('...');
  };
  fetchData();
}, []);

// ✅ 良い例2:IIFE を使用
useEffect(() => {
  (async () => {
    const data = await fetch('...');
  })();
}, []);

4. メモリリークの防止

タイマーやリスナーを登録した場合、必ずクリーンアップ関数で削除してください。削除しないとメモリリークやエラーが発生します:

// ❌ 悪い例:リスナーが削除されない
useEffect(() => {
  window.addEventListener('scroll', handleScroll);
}, []);

// ✅ 良い例:クリーンアップ関数で削除
useEffect(() => {
  const handleScroll = () => {
    // スクロール処理
  };

  window.addEventListener('scroll', handleScroll);

  return () => {
    window.removeEventListener('scroll', handleScroll);
  };
}, []);

5. 開発環境での Strict Mode

React 18 以降の開発環境では、Strict Mode で useEffect が2回実行されます。これはバグを見つけるための仕様です。本番環境では1回だけ実行されるので、過度に心配する必要はありません。

実務で使う場合のベストプラクティス

useEffect を効果的に使うためのベストプラクティスをまとめました:

  1. 責務ごとに useEffect を分ける:1つの useEffect には1つの責務を持たせることで、バグが少なくなり、テストが書きやすくなります。
  2. 依存配列は正確に指定:使用している変数や関数は必ず依存配列に含めてください。
  3. クリーンアップ関数を忘れずに:タイマーやリスナー、接続を使う場合は、必ずクリーンアップしましょう。
  4. デバウンス・スロットルを活用:高頻度で発火する useEffect には、デバウンスやスロットルを入れてパフォーマンス改善を図ります。
  5. カスタムフックでロジックを再利用:複雑な useEffect は、カスタムフックに切り出すことで再利用性を高められます。

カスタムフック化の例

// useFetch.ts: API 取得ロジックを再利用可能に
import { useEffect, useState, useRef } from 'react';

interface UseFetchResult {
  data: T | null;
  loading: boolean;
  error: string | null;
}

export const useFetch = (url: string): UseFetchResult => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const cacheRef = useRef<{ [key: string]: T }>({});
  const abortControllerRef = useRef(null);

  useEffect(() => {
    if (cacheRef.current[url]) {
      setData(cacheRef.current[url]);
      setLoading(false);
      return;
    }

    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }

    abortControllerRef.current = new AbortController();
    const controller = abortControllerRef.current;

    const fetch = async () => {
      try {
        setLoading(true);
        setError(null);

        const response = await fetch(url, { signal: controller.signal });

        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }

        const result: T = await response.json();
        cacheRef.current[url] = result;
        setData(result);
      } catch (err) {
        if (err instanceof Error && err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        setLoading(false);
      }
    };

    fetch();

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

  return { data, loading, error };
};

// 使用方法
export const UserList: React.FC = () => {
  const { data: users, loading, error } = useFetch(
    'https://api.example.com/users'
  );

  if (loading) return 
読み込み中...
; if (error) return
エラー: {error}
; return (
    {users?.map((user: any) => (
  • {user.name}
  • ))}
); };

まとめ

React の useEffect は、コンポーネントのライフサイクルを制御する重要なフックです。実務では以下のポイントを意識することが重要です:

  • キャッシュとリクエストキャンセル:API 呼び出しでは必ず実装
  • デバウンス処理:入力値の検証時に重要
  • クリーンアップ関数:メモリリークを防ぐために必須
  • 複数の useEffect を活用:責務ごとに分けることで保守性向上
  • カスタムフック化:複雑なロジックは再利用可能にする

これらのパターンを実務で活用すれば、バグの少ないスケーラブルな React アプリケーションが開発できます。最初は少し複雑に感じるかもしれませんが、何度も使うことで自然と身につきます。

実務で useEffect を使うときは、常に「このデータが変わったときに実行するべきか」「クリーンアップは必要か」「依存配列は正確か」の3つを意識することをお勧めします。

タイトルとURLをコピーしました