副作用とデータリクエスト — useEffect

この章では、React で最も重要な副作用 Hook である useEffect を学び、適切なタイミングで fetch を使って JSON ファイルからブログデータを読み込みます。


副作用とは?

React では、副作用(Side Effect)レンダリングとは無関係な操作のことを指します:

  • サーバーへのデータリクエスト
  • localStorage の操作
  • タイマー setInterval の設定
  • DOM の手動変更
  • 外部イベントの購読

副作用はコンポーネント関数本体で直接実行することはできません(毎回のレンダリングで実行されてしまいます)。必ずuseEffectの中に置く必要があります。


useEffect 基本の使い方

実例

import { useState, useEffect } from 'react'

function HomePage() {
  const [message, setMessage] = useState('読み込み中...')

  // useEffect(fn, deps):コンポーネントのレンダリング後に副作用を実行
  useEffect(() => {
    console.log('コンポーネントが DOM にマウントされました。副作用を実行できます')
    setMessage(データの読み込みが完了しました!)
  }, [])  // 空の依存配列 = 初回レンダリング後にのみ1回実行

  return <p>{message}</p>
}

依存配列の3つの書き方

書き方実行タイミング使用シーン
useEffect(fn)毎回のレンダリング後に実行ほとんど使用しない。無限ループを引き起こしやすい
useEffect(fn, [])初回レンダリング後にのみ1回実行データ読み込み、サードパーティライブラリの初期化
useEffect(fn, [dep])初回レンダリング後 + dep が変化したときに実行特定のデータの変化を監視し、対応する操作を実行

依存配列の省略 vs 空配列の違い:useEffect(fn)毎回のレンダリング後に実行されます;useEffect(fn, [])初回レンダリング後にのみ1回実行されます。前者は無限ループを引き起こしやすく(effect 内の setState → レンダリング発生 → effect を再実行 → ...)、ほとんど使用されません。


fetch で JSON データを読み込む

Vue3 と同じように、記事データをpublic/posts.jsonファイルの中に置きます。

ステップ1:データファイルを作成

実例

// ファイルパス:public/posts.json
[
  {
    "id": 1,
"title": "React 入門完全ガイド",
"summary": "ゼロから React Hooks を学び、useState、useEffect などの核心概念を網羅。",
"content": "<h2>なぜ React を学ぶのか?</h2><p>React は現在最も人気のあるフロントエンドフレームワークの一つです...</p>",
    "category": "React",
    "date": "2024-05-10"
  },
  {
    "id": 2,
"title": "JavaScript 非同期プログラミング詳解",
"summary": "Promise、async/await、イベントループとマイクロタスクキューを一つの記事で完全に理解。",
"content": "<h2>非同期とは?</h2><p>JS はシングルスレッドです...</p>",
    "category": "JavaScript",
    "date": "2024-05-08"
  },
  {
    "id": 3,
"title": "CSS Grid レイアウト実践",
"summary": "CSS Grid で複雑なレスポンシブレイアウトを簡単に実現。",
"content": "<h2>Grid 入門</h2><p>Grid は二次元レイアウトシステムです...</p>",
    "category": "CSS",
    "date": "2024-05-05"
  }
]

ステップ2:useEffect でデータを読み込む

実例

// ファイルパス:src/pages/HomePage.jsx
import { useState, useEffect, useMemo } from 'react'
import BlogCard from '../components/BlogCard'
import CategoryFilter from '../components/CategoryFilter'

function HomePage() {
  const [articles, setArticles] = useState([])   // 記事データ
  const [isLoading, setIsLoading] = useState(true) // 読み込み状態
  const [error, setError] = useState(null)        // エラーメッセージ
  const [activeCategory, setActiveCategory] = useState('すべて')

  // useEffect でデータ読み込み:空の依存配列 = 初回レンダリング後にのみ1回実行
  useEffect(() => {
    let cancelled = false  // コンポーネントのアンマウント後に setState を防ぐフラグ

    async function fetchPosts() {
      setIsLoading(true)
      setError(null)
      try {
        const res = await fetch('/posts.json')
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        const data = await res.json()
        if (!cancelled) {
          setArticles(data)
        }
      } catch (err) {
        if (!cancelled) {
          setError(err.message)
        }
      } finally {
        if (!cancelled) {
          setIsLoading(false)
        }
      }
    }

    fetchPosts()

    // クリーンアップ関数:コンポーネントのアンマウント時に cancelled を true に設定
    return () => { cancelled = true }
  }, [])

  const categories = useMemo(() => {
    return ['すべて', ...new Set(articles.map(a => a.category))]
  }, [articles])

  const filteredArticles = useMemo(() => {
    if (activeCategory === 'すべて') return articles
    return articles.filter(a => a.category === activeCategory)
  }, [articles, activeCategory])

  return (
    <div>
      <h2 className="section-title">最新記事</h2>

      {/* 読み込み中 */}
      {isLoading && <p className="status-msg">読み込み中です。しばらくお待ちください...</p>}

      {/* 読み込みエラー */}
      {error && (
        <div className="status-msg error">
          <p>読み込みに失敗しました:{error}</p>
          <button onClick={() => window.location.reload()}>再試行</button>
        </div>
      )}

      {/* データ読み込み完了 */}
      {!isLoading && !error && (
        <>
          <CategoryFilter
            categories={categories}
            activeCategory={activeCategory}
            onCategoryChange={setActiveCategory}
          />
          <p className="result-info">共 {filteredArticles.length} 篇</p>
          {filteredArticles.length === 0 ? (
            <p className="empty-tip">このカテゴリには記事がありません</p>
          ) : (
            <div className="article-grid">
              {filteredArticles.map(article => (
                <BlogCard key={article.id} {...article} />
              ))}
            </div>
          )}
        </>
      )}
    </div>
  )
}

export default HomePage

cancelledcancelled フラグは React でよく見られるパターンです。fetch がまだ進行中のときにユーザーがページを離れると(コンポーネントのアンマウント)、その後の setState がエラー警告を出します。cleanup 関数で cancelled を設定するとcancelled = trueアンマウント後に setState をスキップできます。


クリーンアップ関数(Cleanup)

useEffect のコールバック関数は関数を返すことができ、それをクリーンアップ関数。

と呼びます。これは次の2つのタイミングで実行されます:コンポーネントのアンマウント時と次の effect の実行前。

実例

useEffect(() => {
  // 副作用:イベント購読
  const handleScroll = () => console.log('スクロールしました')
  window.addEventListener('scroll', handleScroll)

  // クリーンアップ関数を返す
  return () => {
    // コンポーネントのアンマウント時にイベントリスナーを自動的に削除し、メモリリークを防止
    window.removeEventListener('scroll', handleScroll)
  }
}, [])

useEffect(() => {
  // 副作用:タイマー設定
  const timer = setInterval(() => {
    console.log('毎秒実行')
  }, 1000)

  return () => clearInterval(timer)  // タイマーをクリア
}, [])
副作用クリーンアップが必要?クリーンアップ方法
fetch リクエストであるcancelled フラグまたは AbortController を使用
イベントリスナーであるremoveEventListener
タイマーであるclearInterval / clearTimeout
DOM を変更したら元に戻すであるcleanup で元の状態に戻す
localStorage への書き込み否クリーンアップ不要

非同期リクエストの4つの状態を処理する

すべてのデータリクエストは、以下の4つの UI 状態をカバーする必要があります:

状態条件UI
読み込み中isLoading === trueloading アニメーション、スケルトン画面
成功データが返ってきた通常のコンテンツをレンダリング
失敗error !== nullエラーメッセージ + 再試行ボタン
空データデータが空の配列空状態のメッセージ

VS Vue3 ライフサイクル対照

Vue3React説明
onMounteduseEffect(fn, [])初回レンダリング後に実行
watch(keyword, fn)useEffect(fn, [keyword])特定のデータの変化に応答
onBeforeUnmountuseEffect 的 cleanupコンポーネント破棄前のクリーンアップ
直接の対応なしuseEffect(() => { fn; return cleanup }, [dep])dep が変化したときは cleanup を実行してから fn を実行

章のまとめ

この章では、useEffectの核心のな使い方を習得しました:依存配列の3つの書き方、fetchによる非同期データの読み込み、4つのUI状態の処理、そしてクリーンアップ関数の役割です。

これでブログデータは外部 JSON から動的に読み込まれ、JS にハードコードされなくなりました。

その他の拡張