React Typescript

Синхронізація з ефектами: хук useEffect та функції очищення

Опануйте хук useEffect для підключення React-компонентів до зовнішніх систем: мережевих API, таймерів браузера, DOM-подій та сторонніх бібліотек. Від базових прикладів до складних патернів очищення.

Синхронізація з ефектами: хук useEffect та функції очищення

🔗 Офіційний референс: Синхронізація з ефектами (Synchronizing with Effects)

🎯 Мета лекції

  • Зрозуміти різницю між рендерінгом UI та побічними ефектами.
  • Опанувати синтаксис хука useEffect та його три форми з різними масивами залежностей.
  • Навчитися писати функції очищення для запобігання витокам пам'яті.
  • Застосувати TypeScript для безпечної типізації асинхронних ефектів та підписок.

🔑 Ключові терміни

  • Ефект (Effect): побічна дія, що синхронізує компонент із зовнішньою системою (API, таймер, браузерна подія).
  • Функція очищення (Cleanup Function): функція, яку повертає ефект для скасування підписок, таймерів та з'єднань.
  • Масив залежностей (Dependency Array): список реактивних значень, при зміні яких ефект перезапускається.
  • Strict Mode: режим розробки React, що навмисно подвійно викликає ефекти для виявлення проблем очищення.

Рендеринг проти побічних ефектів: фундаментальна різниця

До цього моменту ми працювали виключно з чистою логікою рендерингу: компоненти приймали пропси, обчислювали стан і повертали JSX-розмітку. Це була математично передбачувана система: одні й ті самі вхідні дані завжди давали однаковий результат без жодних змін зовнішнього світу.

Проте реальні застосунки повинні взаємодіяти з речами за межами React:

  • Завантажувати дані з мережевого API.
  • Підключатися до WebSocket-серверів для отримання оновлень у реальному часі.
  • Змінювати заголовок документа document.title.
  • Відстежувати події браузера (scroll, resize, keydown).
  • Інтегруватися з картографічними бібліотеками, відеоплеєрами, графічними canvas.

Усі ці операції є побічними ефектами (side effects) — діями, що змінюють щось за межами обчислення розмітки. У React є чітке правило: побічні ефекти не повинні виконуватися під час рендерингу. Замість цього вони ізолюються у спеціальний хук useEffect, який виконується після того, як зміни вже застосовані до DOM.

Назва useEffect часто вводить початківців в оману — здається, що це «хук для всього підряд». Насправді його точніше назвати «хуком синхронізації з зовнішніми системами» (synchronization hook). Якщо ваш код не виходить за межі React (наприклад, просто обчислює похідні дані з state), useEffect не потрібен.

Анатомія useEffect: синтаксис та три форми масиву залежностей

Хук useEffect приймає два аргументи:

  1. Функцію ефекту (setup function) — код, що виконає побічну дію.
  2. Масив залежностей (dependency array) — список реактивних значень, при зміні яких ефект перезапускається.

Загальна сигнатура у TypeScript:

useEffect(
  () => {
    // Код побічної дії (setup)
    return () => {
      // Опціональна функція очищення (cleanup)
    };
  },
  [dep1, dep2, ...] // Масив залежностей
);

Існує три фундаментальні форми useEffect залежно від масиву залежностей:

ФормаКоли виконуєтьсяПриклад використання
useEffect(() => { ... })Після кожного рендеруЖурналювання кожної зміни (рідко потрібно)
useEffect(() => { ... }, [])Один раз при монтуванніПочаткове завантаження даних, підписки
useEffect(() => { ... }, [a, b])При зміні a або bСинхронізація з prop/state
Відсутність масиву залежностей (useEffect(() => {...}) без другого аргументу) створює ефект, що виконується після кожного рендеру. Це призводить до каскадних оновлень та зациклень. Завжди явно вказуйте масив залежностей, навіть якщо він порожній.

Перший ефект: змінюємо заголовок документа

Почнемо з найпростішого прикладу — зміни заголовка браузерної вкладки залежно від стану компонента:

import { useState, useEffect } from 'react'

export function DocumentTitleExample() {
    const [count, setCount] = useState<number>(0)

    // Ефект синхронізує заголовок документа зі станом count
    useEffect(() => {
        document.title = `Ви натиснули ${count} разів`
    }, [count]) // Перезапускається щоразу, коли count змінюється

    return (
        <div>
            <p>Відкрийте заголовок вкладки браузера — він змінюється!</p>
            <p>Поточне значення: {count}</p>
            <button onClick={() => setCount(count + 1)}>+1</button>
            <button onClick={() => setCount(0)}>Скинути</button>
        </div>
    )
}
React Зміна заголовка документа
Файли проєкту
src
App.tsx
styles.css
localhost:3000

Збирання React застосунку…

Що тут відбувається:

  1. React рендерить компонент і оновлює DOM.
  2. Браузер малює зміни на екрані (користувач бачить нове значення count).
  3. Після малювання React викликає функцію ефекту, яка змінює document.title.
  4. Коли count змінюється знову, React повторює цикл.

Ключовий момент: ефект виконується асинхронно після рендерингу, а не під час нього. Це гарантує, що користувач миттєво бачить оновлений інтерфейс, навіть якщо побічна дія займає час.


Ефект із порожнім масивом залежностей: виконання один раз при монтуванні

Якщо передати порожній масив [] як другий аргумент, ефект виконається лише один раз — коли компонент вперше з'явиться на екрані. Це ідеально для:

  • Початкового завантаження даних з API.
  • Підписки на глобальні події (window.resize, document.keydown).
  • Ініціалізації сторонніх бібліотек (карти, відеоплеєри).
import { useState, useEffect } from 'react'

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

export function UserProfileLoader() {
    const [user, setUser] = useState<User | null>(null)
    const [isLoading, setIsLoading] = useState<boolean>(true)

    useEffect(() => {
        // Симулюємо мережевий запит
        const fetchUser = async () => {
            setIsLoading(true)
            // У реальному застосунку тут був би fetch('/api/user')
            await new Promise((resolve) => setTimeout(resolve, 1000))

            setUser({
                id: 1,
                name: 'Олександра Петренко',
                email: 'oleksandra@example.com',
            })
            setIsLoading(false)
        }

        fetchUser()
    }, []) // Порожній масив: виконується лише при монтуванні

    if (isLoading) {
        return <p>Завантаження профілю...</p>
    }

    if (!user) {
        return <p>Помилка завантаження даних</p>
    }

    return (
        <div>
            <h3>{user.name}</h3>
            <p>{user.email}</p>
        </div>
    )
}
Порожній масив залежностей [] означає «жодне реактивне значення не впливає на цей ефект». React виконає його один раз після першого рендеру і більше ніколи не торкнеться, доки компонент існує на екрані.

Функція очищення: запобігання витокам пам'яті та завислим підпискам

Багато побічних ефектів створюють ресурси, які потрібно звільнити: таймери, мережеві з'єднання, підписки на події, інтервали. Якщо не очистити їх належним чином, виникають витоки пам'яті (memory leaks) — застосунок споживає все більше ресурсів, поки не сповільниться або не зависне.

Функція очищення (cleanup function) — це функція, яку ви повертаєте з ефекту. React викликає її у двох випадках:

  1. Перед повторним виконанням ефекту (коли залежності змінилися).
  2. При остаточному розмонтуванні компонента (видаленні з DOM).

Приклад: таймер із автоматичним очищенням

Створимо секундомір, що оновлюється кожну секунду:

import { useState, useEffect } from 'react'

export function LiveClock() {
    const [time, setTime] = useState<Date>(new Date())

    useEffect(() => {
        console.log('⏰ Запуск таймера')

        // Створюємо інтервал, що оновлює час кожну секунду
        const intervalId = setInterval(() => {
            setTime(new Date())
        }, 1000)

        // Функція очищення: зупиняємо інтервал
        return () => {
            console.log('🛑 Очищення таймера')
            clearInterval(intervalId)
        }
    }, []) // Порожній масив: створюємо інтервал лише один раз

    return (
        <div>
            <h3>Поточний час</h3>
            <p style={{ fontSize: '2rem', fontWeight: 'bold' }}>{time.toLocaleTimeString('uk-UA')}</p>
        </div>
    )
}
React Секундомір із очищенням
Файли проєкту
src
App.tsx
styles.css
localhost:3000

Збирання React застосунку…

Що відбувається при схованні компонента:

  1. Користувач натискає кнопку «Сховати годинник».
  2. React видаляє компонент LiveClock з DOM (розмонтування).
  3. Перед розмонтуванням React викликає функцію очищення: clearInterval(intervalId).
  4. Таймер зупиняється — більше не викликається setTime на неіснуючому компоненті.
Якщо забути написати функцію очищення, setInterval продовжуватиме працювати навіть після видалення компонента. Це призведе до спроб оновлення стану на розмонтованому компоненті — React видасть попередження у консолі: "Can't perform a React state update on an unmounted component".

Strict Mode та подвійне виконання ефектів у розробці

У файлі main.tsx (або index.tsx) ваш застосунок, ймовірно, обгорнутий у <React.StrictMode>:

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'

createRoot(document.getElementById('root')!).render(
    <StrictMode>
        <App />
    </StrictMode>,
)

У режимі розробки (development) Strict Mode навмисно викликає кожен ефект двічі поспіль:

  1. Перший цикл: mount → виконується ефект → cleanup → unmount
  2. Другий цикл: mount → виконується ефект знову

Це схоже на те, ніби React швидко схований і показав компонент заново. Мета — виявити помилки, коли ваша функція очищення не нейтралізує побічну дію належним чином.

Чому це важливо: У реальному застосунку користувач може перейти на іншу сторінку (компонент розмонтується), потім повернутися назад (компонент змонтується знову). Якщо ваш ефект не має коректної функції очищення, при повторному монтуванні створяться дублюючі підписки, таймери або з'єднання.Strict Mode симулює цю ситуацію під час розробки, щоб ви виявили проблему одразу, а не коли застосунок уже працює у продакшні.

Якщо у консолі ви бачите повідомлення ⏰ Запуск таймера двічі поспіль — це нормально і відбувається лише в development. У production-збірці подвійне виконання автоматично відключається.


Підписка на події браузера: відстеження розміру вікна

Коли компонент повинен реагувати на події, що відбуваються за межами React (прокручування, зміна розміру вікна, натискання клавіш), використовується підписка через addEventListener. Обов'язково видаляйте слухача у функції очищення через removeEventListener.

import { useState, useEffect } from 'react'

interface WindowSize {
    width: number
    height: number
}

export function WindowSizeTracker() {
    const [windowSize, setWindowSize] = useState<WindowSize>({
        width: window.innerWidth,
        height: window.innerHeight,
    })

    useEffect(() => {
        const handleResize = (): void => {
            setWindowSize({
                width: window.innerWidth,
                height: window.innerHeight,
            })
        }

        // Підписуємося на подію зміни розміру вікна
        window.addEventListener('resize', handleResize)

        // Функція очищення: видаляємо слухача
        return () => {
            window.removeEventListener('resize', handleResize)
        }
    }, []) // Порожній масив: підписка створюється один раз

    return (
        <div>
            <h3>Розмір вікна браузера</h3>
            <p>Ширина: {windowSize.width}px</p>
            <p>Висота: {windowSize.height}px</p>
            <p style={{ fontSize: '0.875rem', color: '#64748b' }}>Спробуйте змінити розмір вікна браузера</p>
        </div>
    )
}
React Відстеження розміру вікна
Файли проєкту
src
App.tsx
styles.css
localhost:3000

Збирання React застосунку…

Ключові моменти:

  • Функція handleResize оголошена всередині useEffect, тому вона має доступ до setWindowSize через замикання.
  • addEventListener та removeEventListener отримують точно ту саму функцію — React видалить саме той слухач, що був доданий.
  • Якщо забути removeEventListener, кожен повторний рендер додаватиме новий слухач, створюючи витік пам'яті.

Асинхронні ефекти: завантаження даних із мережі

Однією з найпоширеніших задач useEffect є завантаження даних із API. Проте тут виникає підступна проблема: функція ефекту не може бути async. Це обмеження React — ефект повинен повертати або undefined, або функцію очищення, а не Promise.

Рішення: оголосити асинхронну функцію всередині ефекту та викликати її одразу.

import { useState, useEffect } from 'react'

interface Post {
    id: number
    title: string
    body: string
}

export function PostLoader({ postId }: { postId: number }) {
    const [post, setPost] = useState<Post | null>(null)
    const [isLoading, setIsLoading] = useState<boolean>(true)
    const [error, setError] = useState<string | null>(null)

    useEffect(() => {
        // Оголошуємо async функцію всередині ефекту
        const fetchPost = async (): Promise<void> => {
            setIsLoading(true)
            setError(null)

            try {
                const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`)

                if (!response.ok) {
                    throw new Error(`HTTP помилка: ${response.status}`)
                }

                const data: Post = await response.json()
                setPost(data)
            } catch (err) {
                setError(err instanceof Error ? err.message : 'Невідома помилка')
            } finally {
                setIsLoading(false)
            }
        }

        // Викликаємо функцію одразу
        fetchPost()
    }, [postId]) // Перезавантажуємо дані при зміні postId

    if (isLoading) {
        return <p>Завантаження публікації...</p>
    }

    if (error) {
        return <p style={{ color: '#ef4444' }}>Помилка: {error}</p>
    }

    if (!post) {
        return <p>Публікацію не знайдено</p>
    }

    return (
        <article>
            <h3>{post.title}</h3>
            <p>{post.body}</p>
        </article>
    )
}
React Завантаження даних із API
Файли проєкту
src
App.tsx
styles.css
localhost:3000

Збирання React застосунку…

Проблема гонки даних (Race Condition): Якщо користувач швидко перемикає postId (наприклад, клікає 1 → 2 → 3), React запустить три асинхронні запити паралельно. Проте ми не можемо гарантувати, що відповіді прийдуть у тому ж порядку — публікація #3 може завантажитися раніше, ніж #2.Результат: користувач вибрав публікацію #3, але на екрані відображається #2 (остання відповідь, яка прийшла). Це класична проблема гонки даних (race condition). Рішення — додати функцію очищення з прапорцем ignore:
useEffect(() => {
    let ignore = false // Прапорець скасування

    const fetchPost = async () => {
        // ... виконуємо запит ...
        if (!ignore) {
            setPost(data) // Оновлюємо стан лише якщо не скасовано
        }
    }

    fetchPost()

    return () => {
        ignore = true // При зміні postId скасовуємо попередній запит
    }
}, [postId])

Ефект із залежностями: синхронізація зі станом та пропсами

Коли ефект повинен перезапускатися у відповідь на зміну конкретних реактивних значень (стану або пропсів), ці значення вказуються у масиві залежностей. React порівнює їх із попередніми значеннями за допомогою Object.is() і перезапускає ефект, якщо хоча б одне змінилося.

import { useState, useEffect } from 'react'

export function SearchLogger({ query }: { query: string }) {
    const [searchCount, setSearchCount] = useState<number>(0)

    useEffect(() => {
        if (query.trim() !== '') {
            console.log(`Пошуковий запит: "${query}"`)
            setSearchCount((prev) => prev + 1)
        }
    }, [query]) // Ефект залежить від пропса query

    return (
        <div>
            <p>
                Поточний запит: <strong>{query || '(порожньо)'}</strong>
            </p>
            <p>Кількість пошуків: {searchCount}</p>
        </div>
    )
}

export function SearchDemo() {
    const [inputValue, setInputValue] = useState<string>('')

    return (
        <div>
            <input
                type="text"
                value={inputValue}
                onChange={(e) => setInputValue(e.target.value)}
                placeholder="Введіть пошуковий запит..."
            />
            <SearchLogger query={inputValue} />
        </div>
    )
}

Кожного разу, коли значення query змінюється, React:

  1. Викликає функцію очищення попереднього ефекту (якщо вона існує).
  2. Виконує нову версію ефекту з актуальним значенням query.
Коли додавати значення у залежності: Якщо ефект читає будь-яке реактивне значення (проп, стан або змінну, обчислену з них), це значення обов'язково має бути у масиві залежностей. Лінтер ESLint із правилом react-hooks/exhaustive-deps допоможе виявити забуті залежності автоматично.

Типові помилки та антипатерни useEffect

1. Забута функція очищення для таймерів

// ❌ Погано: інтервал ніколи не зупиняється
useEffect(() => {
    setInterval(() => {
        console.log('Tick')
    }, 1000)
}, [])

// ✅ Правильно: інтервал очищається при розмонтуванні
useEffect(() => {
    const id = setInterval(() => {
        console.log('Tick')
    }, 1000)

    return () => clearInterval(id)
}, [])

2. Відсутність масиву залежностей

// ❌ Погано: ефект виконується після кожного рендеру
useEffect(() => {
    fetchData()
}) // Масив залежностей відсутній!

// ✅ Правильно: явно вказуємо порожній масив або залежності
useEffect(() => {
    fetchData()
}, [])

3. Неправильна залежність: об'єкт або масив

// ❌ Погано: новий об'єкт створюється при кожному рендері
const options = { page: 1, limit: 10 }

useEffect(() => {
    fetchData(options)
}, [options]) // options — це щоразу новий об'єкт!

// ✅ Правильно: передавайте примітивні значення або useMemo
useEffect(() => {
    fetchData({ page: 1, limit: 10 })
}, []) // Або витягніть page і limit окремо у залежності

4. Async функція як тіло ефекту

// ❌ Погано: useEffect не може повертати Promise
useEffect(async () => {
    const data = await fetchData()
    setData(data)
}, [])

// ✅ Правильно: оголосіть async функцію всередині
useEffect(() => {
    const loadData = async () => {
        const data = await fetchData()
        setData(data)
    }

    loadData()
}, [])

Практичні завдання


Підсумок: коли та як використовувати useEffect

✅ Використовуйте useEffect для:

  • Підписок на події: window.addEventListener, WebSocket, тощо.
  • Мережевих запитів: завантаження даних з API при монтуванні або зміні параметрів.
  • Синхронізації з браузерним API: document.title, localStorage, геолокація.
  • Інтеграції сторонніх бібліотек: ініціалізація карт, графіків, відеоплеєрів.
  • Таймерів та інтервалів: setTimeout, setInterval.

❌ Не використовуйте useEffect для:

  • Трансформації даних для рендерингу: обчислюйте похідні значення прямо під час рендеру або через useMemo.
  • Обробки подій користувача: мережеві запити та зміни стану належать до обробників подій (onClick, onSubmit).
  • Ініціалізації стану: використовуйте ліниву ініціалізацію useState(() => computeInitial()).
  • Сповіщення батьківського компонента: викликайте колбеки з пропсів безпосередньо в обробниках подій.
Золоте правило useEffect: Якщо ваш код не виходить за межі React (не торкається DOM, мережі, таймерів або сторонніх бібліотек), швидше за все, вам не потрібен useEffect. Спочатку спробуйте вирішити задачу через звичайну логіку рендерингу.

Довідкова таблиця: три форми useEffect

СинтаксисКоли виконується setupКоли виконується cleanupПриклад використання
useEffect(() => { ... })Після кожного рендеруПеред кожним повторним виконанням і при розмонтуванніЖурналювання змін (рідко потрібно)
useEffect(() => { ... }, [])Один раз при монтуванніОдин раз при розмонтуванніПочаткове завантаження даних, підписки
useEffect(() => { ... }, [a, b])При монтуванні та при зміні a або bПеред повторним виконанням (із попередніми a, b) і при розмонтуванніСинхронізація з пропсами/станом
Завжди вказуйте масив залежностей явно: Відсутність другого аргументу (useEffect(() => {...})) створює ефект, що запускається після кожного рендеру, включаючи ті, які спричинені самим ефектом. Це призводить до нескінченних циклів оновлення.Використовуйте лінтер eslint-plugin-react-hooks із правилом exhaustive-deps, щоб автоматично виявляти забуті залежності.

Наступні кроки

У цій лекції ви опанували фундаментальні принципи useEffect: синхронізацію з зовнішніми системами, функції очищення та масиви залежностей. Проте існують складніші сценарії та поширені антипатерни, які заслуговують на окреме вивчення:

  • Наступна лекція: «Можливо, вам не потрібен ефект» — розглянемо типові помилки надмірного використання useEffect та правильні альтернативи через обчислювані значення, обробники подій та ключ key.
  • Подальші теми: життєвий цикл реактивних ефектів, розділення подій та ефектів, оптимізація залежностей, кастомні хуки для інкапсуляції логіки ефектів.
Практична рекомендація: Перегляньте файли проєктів-довідників із каталогу модуля:Запустіть їх локально (npm install && npm run dev), відкрийте консоль браузера та експериментуйте зі зміною залежностей, видаленням функцій очищення та перемиканням між компонентами для глибшого розуміння життєвого циклу ефектів.
Copyright © 2026