useEffect синхронизирует компонент с системой вне React — таймером, подпиской, сетью, API браузера: код эффекта выполняется после того, как React обновил экран, а функция очистки отменяет его перед следующим запуском и при удалении компонента. Зависимости не выбирают: в массив попадают все props, состояние и переменные компонента, которые читает эффект, и это проверяет линтер. Если внешней системы нет — данные можно вычислить при рендере или действие вызвано кликом, — эффект не нужен.
После материала вы сможете
- написать эффект с правильными зависимостями и функцией очистки для таймера и запроса с
AbortController - объяснить двойной запуск эффекта в StrictMode и проверить, что эффект его выдерживает
- найти и устранить гонку запросов при загрузке данных и проверить это тестом с поддельными таймерами
- заменить лишние эффекты вычислением при рендере, обработчиком события или
key
Проверено на
React 19.3.0TypeScript 6.0.3TypeScript 7.0.2Vite 8.3.4Vitest 5.0.3@testing-library/react 16.3.3ESLint 10.12.0eslint-plugin-react-hooks 7.1.1Node.js 24 LTS (24.18.0)
Сверено с документацией: 11 октября 2026 г.
Содержание
- Эффект — синхронизация с внешней системой
- Массив зависимостей и почему линтер прав
- Очистка: таймеры, подписки, AbortController
- StrictMode и двойной запуск в разработке
- Загрузка данных: гонки и когда брать библиотеку
- Вам не нужен эффект: производные данные и обработчики
- Чек-лист перед useEffect, тесты и практика
Эффект — синхронизация с внешней системой
В React-компоненте код живёт в трёх местах. Отрисовка только вычисляет разметку из props и состояния. Обработчики событий делают что-то в ответ на конкретное действие пользователя: клик, ввод, отправку формы. Эффекты — третье место: документация описывает их как код, который выполняется после отрисовки, чтобы синхронизировать компонент с системой вне React.
Тело компонента (рендер)
- Когда выполняется
- при каждой отрисовке; должен быть чистым
- Пример
- посчитать итог корзины, отфильтровать список
Обработчик события
- Когда выполняется
- в ответ на конкретное действие
- Пример
- отправить заявку по клику «Записаться»
Эффект
- Когда выполняется
- после того как React обновил экран, — потому что компонент показан
- Пример
- запустить таймер брони, подписаться на события, загрузить данные для текущего запроса
Документация React называет эффекты «запасным выходом» (escape hatch): ими пользуются, когда нужно выйти за пределы React и нет встроенного решения. Отсюда главный вопрос всего урока — есть ли здесь внешняя система? Если её нет, эффект почти наверняка лишний.
Урок продолжает траекторию: после useState соберём для салона красоты таймер брони и поиск услуг с загрузкой с сервера, проверим их тестами и разберём четыре эффекта, которые стоит удалить. Нужны знания состояния и useState, а также Promise и async/await.
npm create vite@latest booking-search -- --template react-ts
cd booking-search
npm install
npm install -D vitest jsdom @testing-library/react @testing-library/dom
npm install -D eslint eslint-plugin-react-hooks typescript-eslintМассив зависимостей и почему линтер прав
Второй аргумент useEffect — массив зависимостей. React сравнивает каждую зависимость с прошлым значением через Object.is и перезапускает эффект, только если что-то изменилось.
useEffect(fn)
- Когда выполняется эффект
- после каждой отрисовки
useEffect(fn, [])
- Когда выполняется эффект
- один раз при появлении компонента (в разработке под StrictMode — с одним дополнительным циклом, см. ниже)
useEffect(fn, [a, b])
- Когда выполняется эффект
- при появлении и после отрисовок, в которых изменились
aилиb
Главное правило из документации: зависимости не выбирают. Реактивные значения — props, состояние и все переменные, объявленные в теле компонента, — могут измениться при следующей отрисовке. Если эффект их читает, они обязаны быть в массиве. Иначе эффект продолжит работать со старыми значениями. Проверку делает правило exhaustive-deps из eslint-plugin-react-hooks; документация прямо говорит, что все его замечания обоснованы, а подавлять линтер не стоит.
import reactHooks from 'eslint-plugin-react-hooks'
import { defineConfig } from 'eslint/config'
import tseslint from 'typescript-eslint'
export default defineConfig([
{
files: ['src/**/*.{ts,tsx}'],
languageOptions: { parser: tseslint.parser },
},
reactHooks.configs.flat.recommended,
])Если эффект не должен перезапускаться при изменении значения, нужно доказать, что оно не реактивное: вынести константу за пределы компонента или объявить её внутри самого эффекта. Отдельная ловушка — объекты и функции, созданные в теле компонента: на каждой отрисовке это новые значения, и эффект с такими зависимостями будет перезапускаться чаще, чем нужно. Обычно их переносят внутрь эффекта.
Очистка: таймеры, подписки, AbortController
Эффект может вернуть функцию очистки. React вызывает её перед каждым повторным запуском эффекта и один раз при удалении компонента со страницы. Очистка — зеркало эффекта: запустили интервал — остановите, подписались — отпишитесь, отправили запрос — отмените.
import { useEffect, useState } from 'react'
function format(totalSeconds: number) {
const minutes = Math.floor(totalSeconds / 60)
const seconds = String(totalSeconds % 60).padStart(2, '0')
return `${minutes}:${seconds}`
}
export function HoldTimer({ initialSeconds }: { initialSeconds: number }) {
const [left, setLeft] = useState(initialSeconds)
const expired = left <= 0
useEffect(() => {
if (expired) return // синхронизировать нечего: таймер больше не нужен
const id = setInterval(() => {
setLeft((s) => s - 1)
}, 1000)
return () => clearInterval(id) // очистка: при размонтировании и перед новым запуском
}, [expired])
return <p role="status">{expired ? 'Бронь истекла' : `Время держится ещё ${format(left)}`}</p>
}- Внешняя система здесь — интервал браузера. Эффект запускает его, очистка останавливает.
- Зависимость —
expired, а неleft. Если поставитьleft, интервал пересоздавался бы каждую секунду. Внутри интервала используется функция обновленияsetLeft((s) => s - 1), поэтому текущееleftэффекту читать не нужно. - Когда время выходит,
expiredстановитсяtrue: React вызывает очистку старого эффекта, а новый запуск сразу выходит — интервалов больше нет. Это проверяет тест черезvi.getTimerCount(). initialSecondsпо соглашению из документации назван с префиксомinitial: prop задаёт только начальное значение, и его дальнейшие изменения игнорируются.
setInterval, setTimeout
- Что делает очистка
clearInterval,clearTimeout
addEventListener
- Что делает очистка
removeEventListenerс той же функцией
Подключение к чату, WebSocket
- Что делает очистка
disconnect,close
fetch с AbortController
- Что делает очистка
controller.abort()— ответ на устаревший запрос не придёт
Анимация, сторонний виджет
- Что делает очистка
- остановка,
destroyили возврат в исходное состояние
StrictMode и двойной запуск в разработке
Шаблон Vite оборачивает приложение в StrictMode. По документации, в режиме разработки он делает дополнительные проверки: повторную отрисовку компонентов, повторный запуск эффектов и проверку устаревших API. Все они работают только при разработке и не влияют на продакшен-сборку.
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)Для эффектов это значит: React монтирует компонент, сразу размонтирует и монтирует снова. Вы видите цепочку запуск → очистка → запуск. Это не ошибка, а проверка: если эффект без правильной очистки, двойной запуск это покажет. Тест подтверждает поведение нашего таймера:
it('в StrictMode при разработке: запуск → очистка → запуск', () => {
const setSpy = vi.spyOn(globalThis, 'setInterval')
const clearSpy = vi.spyOn(globalThis, 'clearInterval')
render(
<StrictMode>
<HoldTimer initialSeconds={60} />
</StrictMode>,
)
expect(setSpy).toHaveBeenCalledTimes(2)
expect(clearSpy).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(1) // в итоге работает ровно один интервал
})Документация формулирует правило: пользователь не должен отличать однократный запуск эффекта (как в продакшене) от цепочки запуск → очистка → запуск (как при разработке). Поэтому правильный вопрос — не «как запустить эффект один раз», а «как исправить эффект, чтобы он работал после повторного монтирования». Ответ почти всегда — дописать очистку.
Загрузка данных: гонки и когда брать библиотеку
Поиск услуг — честный случай для эффекта: результаты нужно синхронизировать с текущим текстом запроса, и неважно, как именно он изменился. Начнём с наивной версии, которая встречается чаще всего:
import { useEffect, useState } from 'react'
import { searchServices, type Service } from '../api/services.ts'
export function NaiveSearch() {
const [query, setQuery] = useState('')
const [services, setServices] = useState<Service[]>([])
useEffect(() => {
if (query === '') return
// 🔴 нет очистки: кто ответил последним, тот и победил
searchServices(query, new AbortController().signal).then(setServices)
}, [query])
return (
<section>
<label>
Поиск услуги <input value={query} onChange={(e) => setQuery(e.target.value)} />
</label>
<ul>
{services.map((service) => (
<li key={service.id}>{service.title}</li>
))}
</ul>
</section>
)
}Пользователь печатает «ма», затем «маникюр». Уходят два запроса, и ответы могут прийти в любом порядке. Мы смоделировали в тесте медленный первый ответ (500 мс) и быстрый второй (100 мс): после обоих на экране остался список для «ма», хотя в поле написано «маникюр». Это гонка (race condition), и линтер её не видит — код формально корректен.
export interface Service {
id: string
title: string
}
export async function searchServices(query: string, signal: AbortSignal): Promise<Service[]> {
const response = await fetch(`/api/services?q=${encodeURIComponent(query)}`, { signal })
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return (await response.json()) as Service[]
}import { useEffect, useState } from 'react'
import { searchServices, type Service } from '../api/services.ts'
type Result =
| { query: string; services: Service[] }
| { query: string; error: string }
export function ServiceSearch() {
const [query, setQuery] = useState('')
const [result, setResult] = useState<Result | null>(null)
useEffect(() => {
if (query === '') return
const controller = new AbortController()
searchServices(query, controller.signal)
.then((services) => setResult({ query, services }))
.catch(() => {
if (controller.signal.aborted) return // запрос отменён нами — это не ошибка
setResult({ query, error: 'Не удалось загрузить услуги' })
})
return () => controller.abort() // устаревший запрос отменяется
}, [query])
// «загрузка» не хранится в состоянии: это ответ не на текущий запрос
const isLoading = query !== '' && result?.query !== query
const current = result?.query === query ? result : null
return (
<section>
<label>
Поиск услуги <input value={query} onChange={(e) => setQuery(e.target.value)} />
</label>
{query === '' && <p>Начните вводить название</p>}
{isLoading && <p>Ищем…</p>}
{current && 'error' in current && <p role="alert">{current.error}</p>}
{current && 'services' in current && (
<ul>
{current.services.map((service) => (
<li key={service.id}>{service.title}</li>
))}
</ul>
)}
</section>
)
}- Каждый запуск эффекта создаёт свой
AbortController, а очистка вызываетabort(). Когда запрос меняется, React сначала очищает прошлый эффект — устаревшийfetchотменяется и завершается ошибкойAbortError, которую мы не показываем. - В состоянии хранится результат вместе с запросом, на который он пришёл. Флаг загрузки не хранится: «загрузка» — это когда последний результат не для текущего запроса. Так не нужно вызывать
setStateсинхронно в теле эффекта, и интерфейс не может показать чужой ответ. - Документация React предлагает и более простой вариант очистки — флаг
let ignore = false, который очистка переключает вtrue, а ответ игнорируется.AbortControllerдополнительно отменяет сам сетевой запрос.
У загрузки данных в эффектах есть ограничения, которые документация перечисляет прямо: эффекты не выполняются на сервере, легко получить «водопад» последовательных запросов, нет предзагрузки и кеша, много повторяющегося кода. Официальная рекомендация — во фреймворке использовать его встроенный механизм загрузки данных, а без фреймворка — клиентский кеш вроде TanStack Query или SWR. Ручной эффект, как в этом уроке, полезно понимать: именно это библиотеки делают внутри.
Вам не нужен эффект: производные данные и обработчики
Документация выделяет два частых случая, когда эффект не нужен: чтобы преобразовать данные для отрисовки и чтобы обработать действие пользователя. Вот четыре эффекта, которые регулярно встречаются в реальном коде:
import { useEffect, useState } from 'react'
import type { Service } from '../api/services.ts'
// 1. Производные данные через эффект
export function FilteredList({ services, filter }: { services: Service[]; filter: string }) {
const [visible, setVisible] = useState<Service[]>([])
useEffect(() => {
setVisible(services.filter((s) => s.title.includes(filter)))
}, [services, filter])
return <p>Найдено: {visible.length}</p>
}
// 2. Действие пользователя через эффект
export function BookButton({ serviceId }: { serviceId: string }) {
const [booked, setBooked] = useState(false)
useEffect(() => {
if (booked) {
fetch('/api/bookings', { method: 'POST', body: JSON.stringify({ serviceId }) })
}
}, [booked, serviceId])
return <button onClick={() => setBooked(true)}>Записаться</button>
}
// 3. Сброс состояния при смене prop через эффект
export function ReviewForm({ serviceId }: { serviceId: string }) {
const [text, setText] = useState('')
useEffect(() => {
setText('')
}, [serviceId])
return <textarea value={text} onChange={(e) => setText(e.target.value)} />
}
// 4. Забытая зависимость
export function PageTitle({ name }: { name: string }) {
useEffect(() => {
document.title = `Запись: ${name}`
}, [])
return null
}$ npx eslint src/bad/Unneeded.tsx
src/bad/Unneeded.tsx
8:5 error Error: Calling setState synchronously within an effect can trigger cascading renders
…
> 8 | setVisible(services.filter((s) => s.title.includes(filter)))
| ^^^^^^^^^^ Avoid calling setState() directly within an effect
… react-hooks/set-state-in-effect
28:5 error Error: Calling setState synchronously within an effect can trigger cascading renders
…
> 28 | setText('')
| ^^^^^^^ Avoid calling setState() directly within an effect
… react-hooks/set-state-in-effect
37:6 warning React Hook useEffect has a missing dependency: 'name'. Either include it or remove the dependency array react-hooks/exhaustive-deps
✖ 3 problems (2 errors, 1 warning)Линтер поймал три случая из четырёх. Правило set-state-in-effect из рекомендованного набора запрещает синхронный setState в теле эффекта: это лишняя отрисовка каскадом. А BookButton формально корректен, и никакой линтер не скажет, что заявка должна уходить из обработчика. Документация советует спросить себя, почему код должен выполниться: если потому, что компонент показан, — это эффект, если потому, что пользователь нажал кнопку, — обработчик.
import { useEffect, useState } from 'react'
import type { Service } from '../api/services.ts'
// 1. Производные данные — вычисляем при рендере
export function FilteredList({ services, filter }: { services: Service[]; filter: string }) {
const visible = services.filter((s) => s.title.includes(filter))
return <p>Найдено: {visible.length}</p>
}
// 2. Действие пользователя — в обработчике события
export function BookButton({ serviceId }: { serviceId: string }) {
const [booked, setBooked] = useState(false)
function handleClick() {
setBooked(true)
fetch('/api/bookings', { method: 'POST', body: JSON.stringify({ serviceId }) })
}
return (
<button onClick={handleClick} disabled={booked}>
{booked ? 'Заявка отправлена' : 'Записаться'}
</button>
)
}
// 3. Сброс состояния — через key у родителя
export function ReviewForm() {
const [text, setText] = useState('')
return (
<textarea aria-label="Отзыв" value={text} onChange={(e) => setText(e.target.value)} />
)
}
export function ReviewSection({ serviceId }: { serviceId: string }) {
return <ReviewForm key={serviceId} />
}
// 4. Все реактивные значения — в зависимостях
export function PageTitle({ name }: { name: string }) {
useEffect(() => {
document.title = `Запись: ${name}`
}, [name])
return null
}setVisible(filter(...)) в эффекте
- Чем заменить
- вычисление при рендере:
const visible = … - Почему
- одна отрисовка вместо двух; дорогие вычисления кешируют
useMemo
Запрос после смены флага booked
- Чем заменить
- запрос в обработчике клика
- Почему
- это действие пользователя, а не синхронизация; тест подтверждает один запрос на клик даже под StrictMode
setText('') при смене serviceId
- Чем заменить
key={serviceId}у компонента- Почему
- React считает компоненты с разным
keyразными и сбрасывает всё их состояние
[] при чтении name
- Чем заменить
[name]- Почему
- все реактивные значения — в зависимостях
Чек-лист перед useEffect, тесты и практика
- Есть ли внешняя система — таймер, подписка, сеть, DOM вне React, сторонний виджет? Если нет, эффект не нужен.
- Можно ли вычислить значение из props и состояния при рендере? Тогда это переменная, а не состояние с эффектом.
- Код выполняется, потому что пользователь что-то сделал? Тогда его место — в обработчике события.
- Нужно сбросить всё состояние при смене prop? Передайте компоненту другой
key. - Что отменяет очистка? У каждого запуска — своя пара «запустить/остановить».
- Все ли реактивные значения в зависимостях? Предупреждения
exhaustive-depsисправляют, а не подавляют. - Для загрузки данных: отменяется ли устаревший запрос? Не лучше ли механизм фреймворка или библиотека кеширования?
- Работает ли эффект после цепочки запуск → очистка → запуск в StrictMode?
Тесты фиксируют поведение, о котором шла речь: отсчёт и остановку таймера, очистку при размонтировании, двойной запуск под StrictMode, отмену устаревшего запроса и обработку ошибки сервера. fetch подменён фейком, который отвечает с заданной задержкой и реагирует на AbortSignal, а время управляется поддельными таймерами Vitest.
import { act, fireEvent, render, screen } from '@testing-library/react'
import { StrictMode } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { HoldTimer } from './components/HoldTimer.tsx'
import { ServiceSearch } from './components/ServiceSearch.tsx'
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('HoldTimer', () => {
it('отсчитывает время и останавливает интервал, когда бронь истекла', () => {
render(<HoldTimer initialSeconds={3} />)
expect(screen.getByRole('status').textContent).toBe('Время держится ещё 0:03')
act(() => vi.advanceTimersByTime(1000))
expect(screen.getByRole('status').textContent).toBe('Время держится ещё 0:02')
act(() => vi.advanceTimersByTime(2000))
expect(screen.getByRole('status').textContent).toBe('Бронь истекла')
expect(vi.getTimerCount()).toBe(0) // очистка сработала, интервалов не осталось
})
it('очищает интервал при размонтировании', () => {
const { unmount } = render(<HoldTimer initialSeconds={60} />)
expect(vi.getTimerCount()).toBe(1)
unmount()
expect(vi.getTimerCount()).toBe(0)
})
it('в StrictMode при разработке: запуск → очистка → запуск', () => {
const setSpy = vi.spyOn(globalThis, 'setInterval')
const clearSpy = vi.spyOn(globalThis, 'clearInterval')
render(
<StrictMode>
<HoldTimer initialSeconds={60} />
</StrictMode>,
)
expect(setSpy).toHaveBeenCalledTimes(2)
expect(clearSpy).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(1) // в итоге работает ровно один интервал
})
})
/* поддельный fetch: отвечает с задержкой и честно реагирует на AbortSignal */
function stubFetch(delays: Record<string, number>, signals: AbortSignal[]) {
vi.stubGlobal(
'fetch',
vi.fn((url: string, init: RequestInit) => {
const q = new URL(url, 'http://localhost').searchParams.get('q') ?? ''
signals.push(init.signal!)
return new Promise<Response>((resolve, reject) => {
const timer = setTimeout(() => {
const body = JSON.stringify([{ id: q, title: `Результат для «${q}»` }])
resolve(new Response(body, { status: 200 }))
}, delays[q] ?? 0)
init.signal!.addEventListener('abort', () => {
clearTimeout(timer)
reject(new DOMException('Aborted', 'AbortError'))
})
})
}),
)
}
describe('ServiceSearch', () => {
it('показывает ответ на последний запрос, а устаревший отменяет', async () => {
const signals: AbortSignal[] = []
stubFetch({ ма: 500, маникюр: 100 }, signals) // первый запрос медленнее второго
render(<ServiceSearch />)
const input = screen.getByLabelText('Поиск услуги')
fireEvent.change(input, { target: { value: 'ма' } })
fireEvent.change(input, { target: { value: 'маникюр' } })
expect(screen.getByText('Ищем…')).toBeTruthy()
await act(() => vi.advanceTimersByTimeAsync(600))
expect(signals[0].aborted).toBe(true)
expect(signals[1].aborted).toBe(false)
expect(screen.getAllByRole('listitem').map((li) => li.textContent)).toEqual([
'Результат для «маникюр»',
])
expect(screen.queryByText('Ищем…')).toBeNull()
})
it('показывает ошибку, если сервер ответил не 2xx', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('[]', { status: 500 })))
render(<ServiceSearch />)
fireEvent.change(screen.getByLabelText('Поиск услуги'), { target: { value: 'стрижка' } })
await act(() => vi.advanceTimersByTimeAsync(0))
expect(screen.getByRole('alert').textContent).toBe('Не удалось загрузить услуги')
})
})$ npm test
> booking-search@0.0.0 test
> vitest run
RUN v5.0.3 …/booking-search
Test Files 3 passed (3)
Tests 10 passed (10)Эффекты и состояние — основа почти любого интерактивного компонента. Дальше по траектории React — формы и валидация, где управляемые поля из прошлого урока встречаются с реальными требованиями, а затем управление состоянием в больших приложениях и тестирование фронтенда.
Как проверено7 · пройденосверено 11 октября 2026 г.
- запущено, работает
src/components/HoldTimer.tsx (интервал + очистка, зависимость expired)
vitest 5.0.3 с поддельными таймерами: отсчёт 0:03 → 0:02 → «Бронь истекла», getTimerCount() = 0; размонтирование очищает интервал; в StrictMode setInterval ×2, clearInterval ×1, активен 1 интервал
- запущено, работает
src/api/services.ts, src/components/ServiceSearch.tsx (AbortController, результат с запросом)
фейковый fetch с задержками 500/100 мс и поддержкой AbortSignal: первый запрос отменён, на экране только результат последнего; ответ 500 → role=alert; без abort() в очистке тест падает (мутационная проверка)
- запущено, работает
src/bad/NaiveSearch.tsx (гонка)
тест подтверждает, что без очистки на экране остаётся ответ на «ма» при запросе «маникюр»
- запущено, работает
src/bad/Unneeded.tsx и src/good/Fixed.tsx
ESLint 10.12.0 + eslint-plugin-react-hooks 7.1.1 (flat recommended) + typescript-eslint 8.71.1: на Unneeded 2 ошибки set-state-in-effect и 1 предупреждение exhaustive-deps (вывод скопирован с сокращением), на Fixed/components/api — 0 замечаний; тесты Fixed: фильтр при рендере, один fetch на клик под StrictMode, сброс через key, document.title по name
- запущено, работает
Сборка проекта, eslint.config.js, src/main.tsx (шаблон)
npm run build (tsc -b на TypeScript 6.0.3 + vite build 8.3.4) — успешно; tsc -b на TypeScript 7.0.2 — 0 ошибок; npm test — 3 файла, 10/10
- запущено, работает
Решения упражнений (продление, useDebouncedValue, ReviewDraft)
копия проекта ex/: tsc -b, vite build, eslint — 0 замечаний, vitest 8/8; фрагменты сверены программно
- запущено, работает
EN-версия кода
копии en/ и en-ex/: tsc -b, vite build, vitest 10/10 и 8/8, вывод ESLint совпадает по строкам
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.