← React/useEffect: побочные эффекты#264 из 383← ПредыдущийСледующий →+25 XP
Полезно по теме:Гайд: React или VueПрактика: React setТермин: React HooksТема: React: хуки и экосистема

useEffect: побочные эффекты

Что такое побочные эффекты

Побочный эффект (side effect) — любое действие, которое выходит за пределы возврата JSX:

  • Запросы к API (fetch, axios)
  • Работа с таймерами (setTimeout, setInterval)
  • Подписки на события (addEventListener, WebSocket)
  • Изменение document.title или других внешних API
  • Запись в localStorage
  • Такие действия нельзя делать напрямую в теле функции-компонента — они должны выполняться через useEffect.

    Синтаксис useEffect

    import { useEffect } from 'react'
    
    useEffect(
      () => {
        // Код эффекта — выполнится после рендера
        document.title = `Новых сообщений: ${count}`
    
        // Опциональная функция очистки (cleanup)
        return () => {
          document.title = 'React App'  // восстановить при размонтировании
        }
      },
      [count]  // массив зависимостей
    )

    Массив зависимостей

    Второй аргумент контролирует когда запустится эффект:

    // Запускать при КАЖДОМ рендере (опасно — редко нужно):
    useEffect(() => { console.log('каждый рендер') })
    
    // Запустить ТОЛЬКО при монтировании (один раз):
    useEffect(() => { fetchData() }, [])
    
    // Запускать при изменении userId:
    useEffect(() => { fetchUser(userId) }, [userId])
    
    // Запускать при изменении любого из зависимостей:
    useEffect(() => { updateTitle(name, count) }, [name, count])

    Функция очистки (cleanup)

    Очистка запускается перед размонтированием компонента и перед повторным запуском эффекта:

    useEffect(() => {
      const timer = setInterval(() => {
        setCount(c => c + 1)
      }, 1000)
    
      // Cleanup: отменяем таймер при размонтировании
      return () => clearInterval(timer)
    }, [])
    useEffect(() => {
      const handleResize = () => setSize(window.innerWidth)
      window.addEventListener('resize', handleResize)
    
      // Cleanup: удаляем слушатель
      return () => window.removeEventListener('resize', handleResize)
    }, [])

    Получение данных с useEffect

    function UserProfile({ userId }) {
      const [user, setUser] = useState(null)
      const [isLoading, setIsLoading] = useState(true)
      const [error, setError] = useState(null)
    
      useEffect(() => {
        let cancelled = false  // флаг для предотвращения race condition
    
        setIsLoading(true)
        setError(null)
    
        fetch(`/api/users/${userId}`)
          .then(res => {
            if (!res.ok) throw new Error(`HTTP ${res.status}`)
            return res.json()
          })
          .then(data => {
            if (!cancelled) setUser(data)  // игнорируем если компонент размонтирован
          })
          .catch(err => {
            if (!cancelled) setError(err.message)
          })
          .finally(() => {
            if (!cancelled) setIsLoading(false)
          })
    
        return () => { cancelled = true }  // cleanup: помечаем как отменённый
      }, [userId])  // перезапускать при смене userId
    
      if (isLoading) return <Spinner />
      if (error) return <Error message={error} />
      return <Profile user={user} />
    }

    Частые ошибки

    1. Бесконечный цикл — объект в зависимостях:

    // ОШИБКА: каждый рендер создаёт новый объект options!
    useEffect(() => {
      fetch('/api', { method: 'POST', body: JSON.stringify(options) })
    }, [options])  // options = { page: 1 } — новый объект каждый раз!
    
    // ПРАВИЛЬНО: используй примитивные значения:
    useEffect(() => {
      fetch(`/api?page=${page}&sort=${sort}`)
    }, [page, sort])  // примитивы сравниваются по значению

    2. Функция в зависимостях — аналогично, функция создаётся заново при каждом рендере. Решение: useCallback (изучим позже).

    React StrictMode и двойной вызов

    В React 18 StrictMode намеренно вызывает эффекты дважды в разработке (mount → cleanup → mount) для обнаружения ошибок. Это нормально в разработке и не происходит в продакшне. Именно поэтому важны функции очистки.

    Примеры

    Реализация useEffect через замыкания: управление подписками, таймерами и запросами с cleanup

    // Реализуем упрощённый useEffect чтобы понять механизм
    // зависимостей, cleanup и жизненного цикла
    
    // ============================================================
    // Упрощённая реализация useEffect
    // ============================================================
    
    const effectStore = {
      effects: [],      // список зарегистрированных эффектов
      cleanups: [],     // функции очистки
    }
    
    function useEffect(fn, deps) {
      const index = effectStore.effects.length
    
      const prevDeps = effectStore.effects[index]?.deps
    
      // Проверяем нужно ли перезапустить эффект
      const shouldRun = !prevDeps || !deps ||
        deps.some((dep, i) => dep !== prevDeps[i])
    
      if (shouldRun) {
        // Запускаем cleanup предыдущего эффекта
        if (effectStore.cleanups[index]) {
          console.log(`  [cleanup] эффект #${index} очищается`)
          effectStore.cleanups[index]()
        }
    
        // Запускаем эффект — fn может вернуть cleanup-функцию
        const cleanup = fn()
        effectStore.cleanups[index] = cleanup || null
        effectStore.effects[index] = { fn, deps }
      }
    }
    
    // Симуляция размонтирования компонента
    function unmount() {
      console.log('
    [Размонтирование] Запускаем все cleanup-функции...')
      effectStore.cleanups.forEach((cleanup, i) => {
        if (typeof cleanup === 'function') {
          console.log(`  [cleanup] эффект #${i}`)
          cleanup()
        }
      })
      effectStore.effects.length = 0
      effectStore.cleanups.length = 0
    }
    
    // ============================================================
    // Демонстрация 1: Таймер с cleanup
    // ============================================================
    
    console.log('=== Таймер ===')
    let tickCount = 0
    
    useEffect(() => {
      // В реальном React: setInterval + setState
      const timer = setInterval(() => {
        tickCount++
        console.log(`  Тик #${tickCount}`)
      }, 100)
    
      // Cleanup — важно! Без этого таймер продолжит работать
      return () => {
        clearInterval(timer)
        console.log('  Таймер очищен')
      }
    }, [])  // [] — запустить один раз
    
    // ============================================================
    // Демонстрация 2: Эффект с зависимостями (userId)
    // ============================================================
    
    console.log('
    === Эффект с зависимостями ===')
    let userId = 1
    
    function renderWithUserId(id) {
      console.log(`Рендер с userId=${id}`)
      useEffect(() => {
        console.log(`  [effect] Загружаем данные для userId=${id}`)
        // Имитация fetch — в React здесь был бы fetch()
        // и флаг cancelled для предотвращения race condition:
        let cancelled = false
        setTimeout(() => {
          if (!cancelled) console.log(`  [data] Получены данные пользователя ${id}`)
        }, 50)
    
        return () => {
          cancelled = true
          console.log(`  [cleanup] Отменяем запрос для userId=${id}`)
        }
      }, [id])
    }
    
    renderWithUserId(1)  // запускает эффект
    
    // Через "время" userId меняется:
    effectStore.effects = []  // сбрасываем для демонстрации
    renderWithUserId(2)        // cleanup для userId=1, новый эффект для userId=2
    
    // ============================================================
    // Демонстрация 3: Бесконечный цикл — классическая ошибка
    // ============================================================
    
    console.log('
    === Опасный паттерн (не запускаем, только покажем) ===')
    console.log('ОШИБКА: useEffect(() => setData({}), [data])')
    console.log('  data меняется -> эффект -> setData -> data меняется -> ...')
    console.log('ПРАВИЛЬНО: используй примитивы в deps или [] для загрузки при монтировании')
    
    // Очищаем всё при "размонтировании"
    setTimeout(() => unmount(), 200)

    useEffect: побочные эффекты

    Что такое побочные эффекты

    Побочный эффект (side effect) — любое действие, которое выходит за пределы возврата JSX:

  • Запросы к API (fetch, axios)
  • Работа с таймерами (setTimeout, setInterval)
  • Подписки на события (addEventListener, WebSocket)
  • Изменение document.title или других внешних API
  • Запись в localStorage
  • Такие действия нельзя делать напрямую в теле функции-компонента — они должны выполняться через useEffect.

    Синтаксис useEffect

    import { useEffect } from 'react'
    
    useEffect(
      () => {
        // Код эффекта — выполнится после рендера
        document.title = `Новых сообщений: ${count}`
    
        // Опциональная функция очистки (cleanup)
        return () => {
          document.title = 'React App'  // восстановить при размонтировании
        }
      },
      [count]  // массив зависимостей
    )

    Массив зависимостей

    Второй аргумент контролирует когда запустится эффект:

    // Запускать при КАЖДОМ рендере (опасно — редко нужно):
    useEffect(() => { console.log('каждый рендер') })
    
    // Запустить ТОЛЬКО при монтировании (один раз):
    useEffect(() => { fetchData() }, [])
    
    // Запускать при изменении userId:
    useEffect(() => { fetchUser(userId) }, [userId])
    
    // Запускать при изменении любого из зависимостей:
    useEffect(() => { updateTitle(name, count) }, [name, count])

    Функция очистки (cleanup)

    Очистка запускается перед размонтированием компонента и перед повторным запуском эффекта:

    useEffect(() => {
      const timer = setInterval(() => {
        setCount(c => c + 1)
      }, 1000)
    
      // Cleanup: отменяем таймер при размонтировании
      return () => clearInterval(timer)
    }, [])
    useEffect(() => {
      const handleResize = () => setSize(window.innerWidth)
      window.addEventListener('resize', handleResize)
    
      // Cleanup: удаляем слушатель
      return () => window.removeEventListener('resize', handleResize)
    }, [])

    Получение данных с useEffect

    function UserProfile({ userId }) {
      const [user, setUser] = useState(null)
      const [isLoading, setIsLoading] = useState(true)
      const [error, setError] = useState(null)
    
      useEffect(() => {
        let cancelled = false  // флаг для предотвращения race condition
    
        setIsLoading(true)
        setError(null)
    
        fetch(`/api/users/${userId}`)
          .then(res => {
            if (!res.ok) throw new Error(`HTTP ${res.status}`)
            return res.json()
          })
          .then(data => {
            if (!cancelled) setUser(data)  // игнорируем если компонент размонтирован
          })
          .catch(err => {
            if (!cancelled) setError(err.message)
          })
          .finally(() => {
            if (!cancelled) setIsLoading(false)
          })
    
        return () => { cancelled = true }  // cleanup: помечаем как отменённый
      }, [userId])  // перезапускать при смене userId
    
      if (isLoading) return <Spinner />
      if (error) return <Error message={error} />
      return <Profile user={user} />
    }

    Частые ошибки

    1. Бесконечный цикл — объект в зависимостях:

    // ОШИБКА: каждый рендер создаёт новый объект options!
    useEffect(() => {
      fetch('/api', { method: 'POST', body: JSON.stringify(options) })
    }, [options])  // options = { page: 1 } — новый объект каждый раз!
    
    // ПРАВИЛЬНО: используй примитивные значения:
    useEffect(() => {
      fetch(`/api?page=${page}&sort=${sort}`)
    }, [page, sort])  // примитивы сравниваются по значению

    2. Функция в зависимостях — аналогично, функция создаётся заново при каждом рендере. Решение: useCallback (изучим позже).

    React StrictMode и двойной вызов

    В React 18 StrictMode намеренно вызывает эффекты дважды в разработке (mount → cleanup → mount) для обнаружения ошибок. Это нормально в разработке и не происходит в продакшне. Именно поэтому важны функции очистки.

    Примеры

    Реализация useEffect через замыкания: управление подписками, таймерами и запросами с cleanup

    // Реализуем упрощённый useEffect чтобы понять механизм
    // зависимостей, cleanup и жизненного цикла
    
    // ============================================================
    // Упрощённая реализация useEffect
    // ============================================================
    
    const effectStore = {
      effects: [],      // список зарегистрированных эффектов
      cleanups: [],     // функции очистки
    }
    
    function useEffect(fn, deps) {
      const index = effectStore.effects.length
    
      const prevDeps = effectStore.effects[index]?.deps
    
      // Проверяем нужно ли перезапустить эффект
      const shouldRun = !prevDeps || !deps ||
        deps.some((dep, i) => dep !== prevDeps[i])
    
      if (shouldRun) {
        // Запускаем cleanup предыдущего эффекта
        if (effectStore.cleanups[index]) {
          console.log(`  [cleanup] эффект #${index} очищается`)
          effectStore.cleanups[index]()
        }
    
        // Запускаем эффект — fn может вернуть cleanup-функцию
        const cleanup = fn()
        effectStore.cleanups[index] = cleanup || null
        effectStore.effects[index] = { fn, deps }
      }
    }
    
    // Симуляция размонтирования компонента
    function unmount() {
      console.log('
    [Размонтирование] Запускаем все cleanup-функции...')
      effectStore.cleanups.forEach((cleanup, i) => {
        if (typeof cleanup === 'function') {
          console.log(`  [cleanup] эффект #${i}`)
          cleanup()
        }
      })
      effectStore.effects.length = 0
      effectStore.cleanups.length = 0
    }
    
    // ============================================================
    // Демонстрация 1: Таймер с cleanup
    // ============================================================
    
    console.log('=== Таймер ===')
    let tickCount = 0
    
    useEffect(() => {
      // В реальном React: setInterval + setState
      const timer = setInterval(() => {
        tickCount++
        console.log(`  Тик #${tickCount}`)
      }, 100)
    
      // Cleanup — важно! Без этого таймер продолжит работать
      return () => {
        clearInterval(timer)
        console.log('  Таймер очищен')
      }
    }, [])  // [] — запустить один раз
    
    // ============================================================
    // Демонстрация 2: Эффект с зависимостями (userId)
    // ============================================================
    
    console.log('
    === Эффект с зависимостями ===')
    let userId = 1
    
    function renderWithUserId(id) {
      console.log(`Рендер с userId=${id}`)
      useEffect(() => {
        console.log(`  [effect] Загружаем данные для userId=${id}`)
        // Имитация fetch — в React здесь был бы fetch()
        // и флаг cancelled для предотвращения race condition:
        let cancelled = false
        setTimeout(() => {
          if (!cancelled) console.log(`  [data] Получены данные пользователя ${id}`)
        }, 50)
    
        return () => {
          cancelled = true
          console.log(`  [cleanup] Отменяем запрос для userId=${id}`)
        }
      }, [id])
    }
    
    renderWithUserId(1)  // запускает эффект
    
    // Через "время" userId меняется:
    effectStore.effects = []  // сбрасываем для демонстрации
    renderWithUserId(2)        // cleanup для userId=1, новый эффект для userId=2
    
    // ============================================================
    // Демонстрация 3: Бесконечный цикл — классическая ошибка
    // ============================================================
    
    console.log('
    === Опасный паттерн (не запускаем, только покажем) ===')
    console.log('ОШИБКА: useEffect(() => setData({}), [data])')
    console.log('  data меняется -> эффект -> setData -> data меняется -> ...')
    console.log('ПРАВИЛЬНО: используй примитивы в deps или [] для загрузки при монтировании')
    
    // Очищаем всё при "размонтировании"
    setTimeout(() => unmount(), 200)

    Задание

    Создай компонент App с таймером-секундомером. Используй useState для хранения seconds и useEffect для запуска setInterval. Эффект должен запускаться при монтировании (пустой массив зависимостей) и возвращать функцию очистки clearInterval. Отображай секунды и добавь кнопку "Сбросить".

    Подсказка

    setInterval принимает функцию и интервал: setInterval(() => setSeconds(s => s + 1), 1000). Функция очистки: return () => clearInterval(interval). Сбросить: setSeconds(0). В массиве зависимостей — [running], чтобы эффект перезапускался при паузе.

    Загружаем среду выполнения...
    Загружаем AI-помощника...