デザインについての学習メモブログ

React入門 #11 – useEffectでライフサイクルを理解する

記事内に広告が含まれています。

React入門 #11 – useEffectでライフサイクルを理解する

これまで学んだuseStateは、ユーザーの操作に応じたデータ管理に使われます。

しかし、APIからデータを取得したり、タイマーを設定したり、DOM要素に直接アクセスするなど、「副作用(side effects)」が必要な場合があります。

そのために使うのがuseEffectです。

この記事では、useEffectの基本から応用まで詳しく学んでいきます。

useEffectとは?

先ずは、useEffectの概念と基本構文を押さえておきましょう。

useEffectの概念

useEffectは、コンポーネントがマウント、更新、アンマウントされる時に実行される“処理”を定義するHookです。

💡 マウント/ アンマウント

「マウント」を一言で言うと、そのコンポーネントのHTMLを生成してブラウザのDOMに追加したタイミング

「アンマウント」は、コンポーネントが画面から取り除かれる瞬間

副作用(Side Effect)とは?

レンダリングが「画面を描く仕事」だとすると、副作用は「それ以外の仕事」です。

たとえば「APIにデータを取りに行ったり」、「タイマーをセットしたりする処理」は画面を描くこととは直接関係がないため、「副作用」と呼ばれます。

それらの処理は「副作用」として切り出し、useEffectの中に書くルールになっています。

  • APIからのデータ取得
  • タイマーやインターバルの設定
  • ローカルストレージへの保存
  • イベントリスナーの登録
  • DOMの直接操作

基本的な構文

useEffectの構文は大きく3つの部分でできています。

  • useEffectという関数を呼び出し(6行目)
  • 第一引数: 副作用として実行したい処理としてコールバック関数を指定(6〜8行)
  • 第二引数: 依存配列で「いつ実行するか」を指定します。(8行)

最後にある第二引数の[count]は「ここに書いた値が変化した時」にuseEffectが実行されます。

言い換えれば、この配列の中身を変えることで、実行タイミングをコントロールできます。

⚠️ セットアップ関数とコールバック関数

reactでは、useEffectの第1引数に渡す関数をセットアップ関数と呼びます。

広い意味でJavaScriptではコールバック関数とも呼ばれます。

用語が違うと混乱しますが、同じ部分を指している場合があります。

「useEffectの第1引数の事を指しているか」を認識して

JavaScript版:

JSX
import { useEffect, useState } from 'react'

function Example() {
  const [count, setCount] = useState(0)
  // useEfect(第一引数, 第二引数);
  useEffect(() => {
    console.log('コンポーネントがマウントされた、または依存配列の値が変わった')
  }, [count])  // 依存配列
  
  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  )
}

TypeScript版:

TSX
import { useEffect, useState } from 'react'

function Example(): JSX.Element {
  const [count, setCount] = useState<number>(0)
  
  useEffect(() => {
    console.log('コンポーネントがマウントされた、または依存配列の値が変わった')
  }, [count])
  
  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  )
}

useEffectの実行タイミング

useEffectはいつ実行されるのかを理解することが、使いこなすための第一歩です。

概念の解説でも述べたように基本は以下のタイミングで実行されます。

  • マウント
  • 更新
  • アンマウント

依存配列の有無によって実行タイミングが変わる3つのパターンを見ていきましょう。

パターン1:依存配列なし(毎回実行)

依存配列を省略すると、レンダリングのたびに毎回実行されます。

ほとんどのケースで意図しない動作やパフォーマンス低下の原因になるため、基本的には使わないパターンとして覚えておきましょう。

JavaScript版:

JSX
import { useEffect, useState } from 'react'

function EveryRenderExample() {
  const [count, setCount] = useState(0)
  
  // レンダリングのたびに実行される(非推奨:パフォーマンス問題)
  useEffect(() => {
    console.log('レンダリングのたびに実行')
  })
  
  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  )
}

⚠️ 画面表示の時点で2回実行される?

このコードを動かすと、画面を表示するだけでコンソールログが2回表示されることがあります。

これはReactの「Strict Mode」という開発用の機能が、意図しない副作用を検出するためにコンポーネントを意図的に2回レンダリングしているためです。

本番環境では1回しか実行されないので、バグではありません。

パターン2:空の依存配列(マウント時のみ)

空の配列[]を渡すことで、コンポーネントが画面に表示された最初の1回だけ実行されます。

APIからの初期データ取得など、「ページを開いたときに一度だけ行いたい処理」に使うパターンです。

JavaScript版:

サンプルコードが動きが確認できるようにしたかったので、「非同期通信」や「例外処理」が入っていますが、着目して欲しい部分は[]を渡せばマウント時のみ実行されるかです。

確認できれば良いです。

JSX
import { useEffect, useState } from 'react'

function MountOnlyExample() {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)

  // マウント時のみ1回実行
  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch('https://jsonplaceholder.typicode.com/users/1')
        const json = await response.json()
        setData(json)
      } catch (error) {
        console.error('エラー:', error)
      } finally {
        setLoading(false)
      }
    }

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

  if (loading) return <p>読み込み中...</p>

  return <div>{data.name}</div>
}

export default MountOnlyExample

TypeScript版:

TSX
import { type JSX, useEffect, useState } from 'react'

interface User {
  id: number
  name: string
}

function MountOnlyExample(): JSX.Element {
  const [data, setData] = useState<User | null>(null)
  const [loading, setLoading] = useState<boolean>(true)

  useEffect(() => {
    const fetchData = async (): Promise<void> => {
      try {
        const response = await fetch('https://jsonplaceholder.typicode.com/users/1')
        const json: User = await response.json()
        setData(json)
      } catch (error) {
        console.error('エラー:', error)
      } finally {
        setLoading(false)
      }
    }

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

  if (loading) return <p>読み込み中...</p>

  return <div>{data?.name}</div>
}

export default MountOnlyExample

パターン3:依存配列あり(指定の値が変わったときに実行)

[count]のように値を渡すと、その値が変化したときだけ実行されます。

nameを変えてもuseEffectが動かないことをコードで確認しましょう。

依存配列の「監視対象を絞る」という役割がポイントです。

JavaScript版:

JSX
import { useEffect, useState } from 'react'

function DependencyExample() {
  const [count, setCount] = useState(0)
  const [name, setName] = useState('')
  
  // countが変わったときのみ実行
  useEffect(() => {
    console.log('countが変わりました:', count)
  }, [count])
  
  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
      
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="名前を入力"
      />
      <p>名前: {name}</p>
    </div>
  )
}

export default DependencyExample

TypeScript版:

TSX
import { useEffect, useState } from 'react'

function DependencyExample(): JSX.Element {
  const [count, setCount] = useState<number>(0)
  const [name, setName] = useState<string>('')
  
  useEffect(() => {
    console.log('countが変わりました:', count)
  }, [count])
  
  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
      
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="名前を入力"
      />
      <p>名前: {name}</p>
    </div>
  )
}

export default DependencyExample

クリーンアップ関数

タイマーやイベントリスナーを使う場合、使い終わったら後片付けが必要です。

このセクションでは、コンポーネントが消えるときに自動で実行される「クリーンアップ関数」の書き方を学びます。

基本的なクリーンアップ

return () => { ... }の形で関数を返す(10行目)と、それがクリーンアップ関数になります。

コンソールログで「セットアップ→クリーンアップ→セットアップ」の順に実行されることを確認するのが、このコードの狙いです。

JavaScript版:

JSX
import { useEffect, useState } from 'react'

function CleanupExample() {
  const [count, setCount] = useState(0)
  
  useEffect(() => {
    console.log('セットアップ処理')
    
    // クリーンアップ関数
    return () => {
      console.log('クリーンアップ処理')
    }
  }, [count])
  
  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  )
}

export default CleanupExample

💡 ボタンをクリックすると「クリーンアップ→セットアップ」となるのは?

実際にサンプルコードを実行するとコーンソールには「クリーンアップ→セットアップ」となります。

なぜクリーンアップが先なの?と思う方もいるでしょう。

コードの解釈を読み間違えると、

useEffect第一引数であるセットアップ関数が実行タイミングで2つのconsole.logが順次実行してしまうように見えます。

実際は

セットアップ関数のreturnにセットしたクリーンアップ関数」は、即時に実行されるのではなくReactに預けられた状態になります。

JSX
useEffect(
  //コールバック関数
  () => { return "クリーンアップ関数" } //コールバック関数 
  ,[]
)

流れを整理すると

JSX
ボタンクリックまで待機

ボタンクリック
countが変化再レンダリング
Reactが預かっていたクリーンアップ関数を実行
→「クリーンアップ処理をログ出力
新しいエフェクトを実行
→「セットアップ処理をログ出力

「コンポーネント関数→useEffectのセットアップ関数→クリーンアップ関数」のように関数の中で関数を定義するので混乱しやすいと考えます。

ポイントは、「関数をとして渡す」と「その関数がいつ実行されるか?」を分けて考える事です。

解釈の仕方ひとつで見え方が全然違いますね。

TypeScript版:

TSX
import { useEffect, useState, type JSX } from 'react'

function CleanupExample(): JSX.Element {
  const [count, setCount] = useState<number>(0)
  
  useEffect(() => {
    console.log('セットアップ処理')
    
    return () => {
      console.log('クリーンアップ処理')
    }
  }, [count])
  
  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  )
}

export default CleanupExample

実践例:イベントリスナーの登録と削除

addEventListenerremoveEventListenerをセットで書くことが重要です。

クリーンアップを忘れると、コンポーネントが消えた後もイベントリスナーが残り続け、メモリリークの原因になります。

JavaScript版:

JSX
import { useEffect, useState } from 'react'

function ResizeListener() {
  const [width, setWidth] = useState(window.innerWidth)
  
  useEffect(() => {
    // セットアップ:イベントリスナーを登録
    const handleResize = () => {
      setWidth(window.innerWidth)
    }
    
    window.addEventListener('resize', handleResize)
    
    // クリーンアップ:イベントリスナーを削除
    return () => {
      window.removeEventListener('resize', handleResize)
    }
  }, [])  // マウント時のみ登録
  
  return <p>ウィンドウの幅: {width}px</p>
}

export default ResizeListener

⚠️ 配列[]はマウント時のみでは?

[] の場合

マウント時 → セットアップ関数が実行される(1回のみ) *クリーンアップ関数はreactに預ける

アンマウント時 → クリーンアップ関数が実行される(1回のみ)

TypeScript版:

TSX
import { useEffect, useState, type JSX } from 'react'

function ResizeListener(): JSX.Element {
  const [width, setWidth] = useState<number>(window.innerWidth)
  
  useEffect(() => {
    const handleResize = (): void => {
      setWidth(window.innerWidth)
    }
    
    window.addEventListener('resize', handleResize)
    
    return () => {
      window.removeEventListener('resize', handleResize)
    }
  }, [])
  
  return <p>ウィンドウの幅: {width}px</p>
}

export default ResizeListener

APIからのデータ取得

useEffectの最もよくある使い方が、外部APIからのデータ取得です。

非同期処理をuseEffectの中でどう書くか、基本パターンを習得しましょう。

基本的なデータ取得

注目してほしいのは、useEffectの中にasyncを直接つけず、内部で非同期関数を定義して呼び出している点です。

loadingerrordataの3つの状態を使って、読み込み中・エラー・成功の3パターンを表示し分けるのが基本形です。

JavaScript版:

JSX
import { useEffect, useState } from 'react'

function FetchData() {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)
  
  useEffect(() => {
    // 非同期関数を定義
    const fetchData = async () => {
      try {
        setLoading(true)
        const response = await fetch('https://jsonplaceholder.typicode.com/users/1')
        
        if (!response.ok) {
          throw new Error('データ取得に失敗しました')
        }
        
        const json = await response.json()
        setData(json)
        setError(null)
      } catch (err) {
        setError(err.message)
        setData(null)
      } finally {
        setLoading(false)
      }
    }
    
    fetchData()
  }, [])  // マウント時のみ実行
  
  if (loading) return <p>読み込み中...</p>
  if (error) return <p>エラー: {error}</p>
  
  return (
    <div>
      <h2>{data.name}</h2>
      <p>メール: {data.email}</p>
      <p>電話: {data.phone}</p>
    </div>
  )
}

export default FetchData

TypeScript版:

JS版とTS版の大きな違い

関数の戻り値の型を明示しているかどうかです。TS版ではasync (): Promise<void>と書いています。

async関数は必ずPromiseを返すため、戻り値の型はPromise<void>になります。

voidは「何も返さない」という意味で、データの取得結果はsetDataでstateに渡すだけなので戻り値は不要です。

TSX
import { useEffect, useState, type JSX } from 'react'

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

function FetchData(): JSX.Element {
  const [data, setData] = useState<User | null>(null)
  const [loading, setLoading] = useState<boolean>(true)
  const [error, setError] = useState<string | null>(null)
  
  useEffect(() => {
    const fetchData = async (): Promise<void> => {
      try {
        setLoading(true)
        const response = await fetch('https://jsonplaceholder.typicode.com/users/1')
        
        if (!response.ok) {
          throw new Error('データ取得に失敗しました')
        }
        
        const json: User = await response.json()
        setData(json)
        setError(null)
      } catch (err) {
        setError(err instanceof Error ? err.message : '不明なエラー')
        setData(null)
      } finally {
        setLoading(false)
      }
    }
    
    fetchData()
  }, [])
  
  if (loading) return <p>読み込み中...</p>
  if (error) return <p>エラー: {error}</p>
  
  return (
    <div>
      <h2>{data?.name}</h2>
      <p>メール: {data?.email}</p>
      <p>電話: {data?.phone}</p>
    </div>
  )
}

export default FetchData

URLパラメータに基づいたデータ取得

userIdを依存配列に入れることで、userIdが変わるたびに自動で再取得が走ります。

「依存配列に外から受け取るpropsを入れる」というパターンは実務でも頻出です。

JavaScript版:

JSX
import { useEffect, useState } from 'react'

function FetchUserById({ userId }) {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(true)
  
  useEffect(() => {
    const fetchUser = async () => {
      setLoading(true)
      try {
        const response = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
        const json = await response.json()
        setUser(json)
      } catch (error) {
        console.error('エラー:', error)
      } finally {
        setLoading(false)
      }
    }
    
    fetchUser()
  }, [userId])  // userIdが変わったときに再取得
  
  if (loading) return <p>読み込み中...</p>
  
  return <div><h2>{user.name}</h2></div>
}

export default FetchUserById

TypeScript版:

TSX
import { useEffect, useState, type JSX } from 'react'

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

interface Props {
  userId: number
}

function FetchUserById({ userId }: Props): JSX.Element {
  const [user, setUser] = useState<User | null>(null)
  const [loading, setLoading] = useState<boolean>(true)
  
  useEffect(() => {
    const fetchUser = async (): Promise<void> => {
      setLoading(true)
      try {
        const response = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
        const json: User = await response.json()
        setUser(json)
      } catch (error) {
        console.error('エラー:', error)
      } finally {
        setLoading(false)
      }
    }
    
    fetchUser()
  }, [userId])
  
  if (loading) return <p>読み込み中...</p>
  
  return <div><h2>{user?.name}</h2></div>
}

export default FetchUserById

タイマーの設定

setIntervalを使ったタイマーは、クリーンアップとセットで使う典型例です。

カウントアップ・カウントダウンの実装を通じて、タイマーの正しい扱い方を学びます。

setIntervalの使用

setSeconds(prev => prev + 1)と書いているのがポイントです。

setSeconds(seconds + 1)ではなく関数形式にすることで、古い値を参照してしまうバグを防いでいます。

そしてクリーンアップでclearIntervalを忘れずに呼ぶことが必須です。

JavaScript版:

JSX
import { useEffect, useState } from 'react'

function Timer() {
  const [seconds, setSeconds] = useState(0)
  
  useEffect(() => {
    // タイマーを開始
    const interval = setInterval(() => {
      setSeconds(prev => prev + 1)
    }, 1000)
    
    // クリーンアップ:タイマーをクリア
    return () => clearInterval(interval)
  }, [])
  
  return (
    <div>
      <p>経過時間: {seconds}</p>
    </div>
  )
}

export default Timer

TypeScript版:

TSX
import { useEffect, useState, type JSX } from 'react'

function Timer(): JSX.Element {
  const [seconds, setSeconds] = useState<number>(0)
  
  useEffect(() => {
    const interval = setInterval(() => {
      setSeconds(prev => prev + 1)
    }, 1000)
    
    return () => clearInterval(interval)
  }, [])
  
  return (
    <div>
      <p>経過時間: {seconds}</p>
    </div>
  )
}

export default Timer

カウントダウンタイマー

isActivetimeLeftの2つを依存配列に入れることで、一時停止・再開の状態管理を実現しています。

timeLeftが0になったときにsetIsActive(false)でタイマーを止める流れに注目してください。

JavaScript版:

JSX
import { useEffect, useState } from 'react'

function Countdown() {
  const [timeLeft, setTimeLeft] = useState(60)
  const [isActive, setIsActive] = useState(true)
  
  useEffect(() => {
    if (!isActive || timeLeft === 0) {
      return
    }
    
    const interval = setInterval(() => {
      setTimeLeft(prev => {
        if (prev <= 1) {
          setIsActive(false)
          return 0
        }
        return prev - 1
      })
    }, 1000)
    
    return () => clearInterval(interval)
  }, [isActive, timeLeft])
  
  return (
    <div>
      <p>残り時間: {timeLeft}</p>
      <button onClick={() => setIsActive(!isActive)}>
        {isActive ? '一時停止' : '再開'}
      </button>
    </div>
  )
}

export default Countdown

TypeScript版:

TSX
import { useEffect, useState, type JSX } from 'react'

function Countdown(): JSX.Element {
  const [timeLeft, setTimeLeft] = useState<number>(60)
  const [isActive, setIsActive] = useState<boolean>(true)
  
  useEffect(() => {
    if (!isActive || timeLeft === 0) {
      return
    }
    
    const interval = setInterval(() => {
      setTimeLeft(prev => {
        if (prev <= 1) {
          setIsActive(false)
          return 0
        }
        return prev - 1
      })
    }, 1000)
    
    return () => clearInterval(interval)
  }, [isActive, timeLeft])
  
  return (
    <div>
      <p>残り時間: {timeLeft}</p>
      <button onClick={() => setIsActive(!isActive)}>
        {isActive ? '一時停止' : '再開'}
      </button>
    </div>
  )
}

export default Countdown

ローカルストレージへの保存

ページをリロードしても入力内容を保持したい場合に役立つのがローカルストレージです。

useEffectと組み合わせた読み書きの方法を確認しましょう。

2つのuseEffectを使い分けているのがポイントです。

[]で「マウント時に保存済みデータを読み込む」、[name]で「入力のたびに保存する」と役割を分離することで、シンプルで読みやすいコードになっています。

JavaScript版:

JSX
import { useEffect, useState } from 'react'

function LocalStorageExample() {
  // localStorageから初期値を取得
  const [name, setName] = useState(() => localStorage.getItem('userName') || '')

  // nameが変わった時に保存するuseEffectのみ残す
  useEffect(() => {
    localStorage.setItem('userName', name)
  }, [name])

  return (
    <div>
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="名前を入力"
      />
      <p>保存された名前: {name}</p>
    </div>
  )
}

export default LocalStorageExample

localStorage.getItem()string | nullを返しますが、|| ''でnullの場合は空文字にフォールバックしているため、useState<string>の型と一致します。

TypeScript版:

TSX
import { useEffect, useState, type JSX } from 'react'

function LocalStorageExample(): JSX.Element {
   // localStorageから初期値を取得(遅延初期化)
  const [name, setName] = useState<string>(
    () => localStorage.getItem('userName') || ''
  )

  // nameが変わった時に保存するuseEffectのみ残す
  useEffect(() => {
    localStorage.setItem('userName', name)
  }, [name])

  return (
    <div>
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="名前を入力"
      />
      <p>保存された名前: {name}</p>
    </div>
  )
}

export default LocalStorageExample

複数のuseEffect

1つのコンポーネントにuseEffectは複数書くことができます。

目的ごとに分けて書くことで、コードが読みやすく管理しやすくなる理由を理解しましょう。

サンプルコードでは、1つのコンポーネントに3つのuseEffectを書いています。

「countの監視」「messageの監視」「マウント・アンマウントの検知」をそれぞれ独立させることで、どのuseEffectが何をしているか一目でわかる構造になっているのを確認しましょう。

JavaScript版:

JSX
import { useEffect, useState } from 'react'

function MultipleEffects() {
  const [count, setCount] = useState(0)
  const [message, setMessage] = useState('')
  
 // countの変更を監視
  useEffect(() => {
    console.log('countが変わりました:', count)
  }, [count])
  
  // messageの変更を監視
  useEffect(() => {
    console.log('messageが変わりました:', message)
  }, [message])
  
  // マウント時のみ実行
  useEffect(() => {
    console.log('コンポーネントがマウントされました')
    
    return () => {
      console.log('コンポーネントがアンマウントされました')
    }
  }, [])
  
  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
      
      <input
        value={message}
        onChange={(e) => setMessage(e.target.value)}
        placeholder="メッセージを入力"
      />
      <p>メッセージ: {message}</p>
    </div>
  )
}

export default MultipleEffects

TypeScript版:

TSX
import { useEffect, useState, type JSX } from 'react'

function MultipleEffects(): JSX.Element {
  const [count, setCount] = useState<number>(0)
  const [message, setMessage] = useState<string>('')
  
  useEffect(() => {
    console.log('countが変わりました:', count)
  }, [count])
  
  useEffect(() => {
    console.log('messageが変わりました:', message)
  }, [message])
  
  useEffect(() => {
    console.log('コンポーネントがマウントされました')
    
    return () => {
      console.log('コンポーネントがアンマウントされました')
    }
  }, [])
  
  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
      
      <input
        value={message}
        onChange={(e) => setMessage(e.target.value)}
        placeholder="メッセージを入力"
      />
      <p>メッセージ: {message}</p>
    </div>
  )
}

export default MultipleEffects

よくあるuseEffectのミス

useEffectで初学者がハマりやすいミスは、ほぼ3パターンに絞られます。

❌と✅の対比で確認し、同じミスを繰り返さないようにしましょう。

ミス1:依存配列を忘れる

❌ 悪い例(毎回実行される):

JSX
useEffect(() => {
  fetch('/api/data').then(res => res.json()).then(setData)
  // 依存配列がないため、毎回APIを呼ぶ
})

✅ 良い例:

JSX
useEffect(() => {
  fetch('/api/data').then(res => res.json()).then(setData)
}, [])  // マウント時のみ

ミス2:無限ループ

❌ 悪い例:

useEffect実行→setCountでcount変わる→useEffect実行→・・・

JSX
const [count, setCount] = useState(0)

useEffect(() => {
  setCount(count + 1)
}, [count])  // countが変わるたびに、countが変わる→無限ループ

✅ 良い例:

JSX
const [count, setCount] = useState(0)

useEffect(() => {
  console.log('countが変わりました:', count)
}, [count])  // countを監視するだけ

ミス3:クリーンアップ忘れ

❌ 悪い例:

JSX
useEffect(() => {
  const interval = setInterval(() => {
    // タイマーが残り続ける
  }, 1000)
}, [])

✅ 良い例:

JSX
useEffect(() => {
  const interval = setInterval(() => {
    // ...
  }, 1000)
  
  return () => clearInterval(interval)  // クリーンアップ
}, [])

実践:ユーザー一覧の取得と検索

ここまで学んだuseEffectの知識を組み合わせて、APIからデータを取得しつつ検索機能も持つコンポーネントを作ります。

実際に動くものを作ることで理解を定着させましょう。

useEffectはAPIからのデータ取得のみに使い、検索によるフィルタリングはuseEffectを使わずに変数で処理しているのがポイントです。

「useEffectを使うべき処理」と「そうでない処理」を区別する感覚をここで掴みましょう。

JavaScript版:

JSX
import { useEffect, useState } from 'react'

function UserListWithSearch() {
  const [users, setUsers] = useState([])
  const [loading, setLoading] = useState(true)
  const [searchTerm, setSearchTerm] = useState('')
  
  // マウント時にユーザーデータを取得
  useEffect(() => {
    const fetchUsers = async () => {
      try {
        setLoading(true)
        const response = await fetch('https://jsonplaceholder.typicode.com/users')
        const data = await response.json()
        setUsers(data)
      } catch (error) {
        console.error('エラー:', error)
      } finally {
        setLoading(false)
      }
    }
    
    fetchUsers()
  }, [])
  
  // フィルタリング
  const filteredUsers = users.filter(user =>
    user.name.toLowerCase().includes(searchTerm.toLowerCase())
  )
  
  return (
    <div>
      <h2>ユーザー一覧</h2>
      
      <input
        type="text"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="ユーザー名で検索"
      />
      
      {loading ? (
        <p>読み込み中...</p>
      ) : (
        <ul>
          {filteredUsers.map(user => (
            <li key={user.id}>
              <h3>{user.name}</h3>
              <p>{user.email}</p>
            </li>
          ))}
        </ul>
      )}
      
      <p>検索結果: {filteredUsers.length}</p>
    </div>
  )
}

export default UserListWithSearch

TypeScript版:

TSX
import { useEffect, useState, type JSX } from 'react'

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

function UserListWithSearch(): JSX.Element {
  const [users, setUsers] = useState<User[]>([])
  const [loading, setLoading] = useState<boolean>(true)
  const [searchTerm, setSearchTerm] = useState<string>('')
  
  useEffect(() => {
    const fetchUsers = async (): Promise<void> => {
      try {
        setLoading(true)
        const response = await fetch('https://jsonplaceholder.typicode.com/users')
        const data: User[] = await response.json()
        setUsers(data)
      } catch (error) {
        console.error('エラー:', error)
      } finally {
        setLoading(false)
      }
    }
    
    fetchUsers()
  }, [])
  
  const filteredUsers = users.filter(user =>
    user.name.toLowerCase().includes(searchTerm.toLowerCase())
  )
  
  return (
    <div>
      <h2>ユーザー一覧</h2>
      
      <input
        type="text"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="ユーザー名で検索"
      />
      
      {loading ? (
        <p>読み込み中...</p>
      ) : (
        <ul>
          {filteredUsers.map(user => (
            <li key={user.id}>
              <h3>{user.name}</h3>
              <p>{user.email}</p>
            </li>
          ))}
        </ul>
      )}
      
      <p>検索結果: {filteredUsers.length}</p>
    </div>
  )
}

export default UserListWithSearch

useEffectのベストプラクティス

動くコードが書けるようになったら、次は「良い書き方」を意識しましょう。

現場でも通用するコードにするための3つの習慣を紹介します。

プラクティス1:依存配列を常に明示的に指定

依存配列がないとReactはいつ実行すべきか判断できず、毎回実行されてしまいます。

[][値]か、常に意図を明示する習慣をつけましょう。

❌ 悪い例:

JSX
useEffect(() => {
  // ...
})  // 依存配列がない

✅ 良い例:

JSX
useEffect(() => {
  // ...
}, [])  // マウント時のみ
// または
useEffect(() => {
  // ...
}, [count, name])  // 明示的に指定

プラクティス2:クリーンアップ関数を忘れない

setTimeoutsetIntervalはクリーンアップしないとメモリに残り続けます。

タイマー系の処理には必ずセットでreturn () => clear〇〇()を書くルールにしましょう。

❌ 悪い例:

JSX
useEffect(() => {
  const timer = setTimeout(() => {}, 1000)
  // タイマーがメモリに残る
}, [])

✅ 良い例:

JSX
useEffect(() => {
  const timer = setTimeout(() => {}, 1000)
  
  return () => clearTimeout(timer)
}, [])

プラクティス3:複数のuseEffectで関心を分離

1つのuseEffectに複数の処理を詰め込むと、「どの処理が何のために動いているか」がわかりにくくなります。

目的ごとに分けることで、バグの原因特定もしやすくなります。

❌ 悪い例(1つのuseEffectに多くの処理):

JSX
useEffect(() => {
  // データ取得
  fetch()
  // タイマー設定
  setInterval()
  // イベントリスナー登録
  addEventListener()
}, [])

✅ 良い例(関心を分離):

JSX
// データ取得
useEffect(() => {
  fetch()
}, [])

// タイマー設定
useEffect(() => {
  const interval = setInterval()
  return () => clearInterval(interval)
}, [])

// イベントリスナー登録
useEffect(() => {
  addEventListener()
  return () => removeEventListener()
}, [])

まとめ

この記事では、useEffectでライフサイクルを管理する方法を詳しく学びました。

重要なポイント:

  • useEffectは副作用(side effects)を管理するHook
  • 依存配列で実行タイミングを制御
  • マウント時のみ:[]
  • 指定値が変わったとき:[value]
  • 毎回実行:依存配列なし(非推奨)
  • クリーンアップ関数でリソースを解放

useEffectの典型的な使用例:

  • APIからデータを取得
  • タイマー・インターバルを設定
  • イベントリスナーを登録
  • ローカルストレージに保存
  • DOMに直接アクセス

ベストプラクティス:

  • 常に依存配列を明示的に指定
  • クリーンアップ関数でリソースをクリア
  • 複数のuseEffectで関心を分離
  • 無限ループに注意
  • 非同期処理はuseEffect内で処理

次のステップ:

次回は、useStateとuseEffectを組み合わせてカスタムフックを作成します。

また、useContextなどの高度なHooksについても学びます。

useEffectはReactで最も重要なHooksの一つです。

様々なパターンを実装して、確実に習得しましょう!