Побочный эффект (side effect) — любое действие, которое выходит за пределы возврата JSX:
Такие действия нельзя делать напрямую в теле функции-компонента — они должны выполняться через 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])Очистка запускается перед размонтированием компонента и перед повторным запуском эффекта:
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)
}, [])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 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)Побочный эффект (side effect) — любое действие, которое выходит за пределы возврата JSX:
Такие действия нельзя делать напрямую в теле функции-компонента — они должны выполняться через 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])Очистка запускается перед размонтированием компонента и перед повторным запуском эффекта:
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)
}, [])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 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], чтобы эффект перезапускался при паузе.