React 19 useEffectEvent ta Activity: novi API ta pytannya na spivbesidi 2026

Detalnyi ohliad useEffectEvent ta Activity v React 19.2. Vyrishennia stale closures, fоnovyi pre-rendering, pryklady kodu ta pytannya na tekhnichni spivbesidy.

Diahrama arkhitektury React 19.2 useEffectEvent ta Activity API

React 19 представив два експериментальні API, які змінюють підхід до роботи з побічними ефектами та управління станом компонентів: useEffectEvent та Activity. Перший вирішує давню проблему застарілих замикань (stale closures) в обробниках подій всередині useEffect, а другий дозволяє зберігати стан прихованих компонентів без їх повного розмонтування. Ці API вже доступні в React 19.2 та активно тестуються в production-проєктах. У цій статті розглядаються обидва API з прикладами коду, типовими помилками та питаннями, які зустрічаються на технічних співбесідах у 2026 році.

Вимоги до версії

useEffectEvent доступний починаючи з React 19.2 та потребує прапорця experimental. Activity (раніше відомий як Offscreen) знаходиться в експериментальній фазі та доступний через canary-збірки React. Обидва API можуть змінити свій інтерфейс до стабільного релізу.

Проблема застарілих замикань у useEffect

Одна з найпоширеніших помилок у React-додатках — використання застарілих значень стану або пропсів всередині колбеків useEffect. Коли розробник підписується на подію (WebSocket, таймер, зовнішній listener) і використовує в обробнику значення з пропсів, виникає дилема: або додати це значення до масиву залежностей (що спричинить небажане перепідключення), або використати useRef як обхідне рішення.

Класичний приклад — чат-компонент, де підключення до кімнати має відбуватися лише при зміні roomId, але обробник повідомлень потребує актуального значення theme для аналітики:

hooks/useChatRoom.tstsx
// Before useEffectEvent: useRef workaround
import { useEffect, useRef } from 'react'

export function useChatRoom(roomId: string, theme: string) {
  // Store theme in a ref to avoid stale closure
  const themeRef = useRef(theme)
  themeRef.current = theme

  useEffect(() => {
    const connection = createConnection(roomId)
    connection.on('message', (msg) => {
      // themeRef.current always has the latest value
      logAnalytics('new_message', { roomId, theme: themeRef.current })
      showNotification(msg)
    })
    connection.connect()
    return () => connection.disconnect()
  }, [roomId]) // theme intentionally excluded — but linter warns
}

Цей патерн працює, але має суттєві недоліки. Лінтер React Hooks попереджає про відсутність theme в масиві залежностей. Ручне управління рефами порушує декларативну модель React і ускладнює код-ревʼю. У великих кодових базах такі обхідні рішення множаться і стають джерелом багів.

useEffectEvent: чистий розподіл відповідальностей

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

hooks/useChatRoom.tstsx
// After useEffectEvent: clean separation of concerns
import { useEffect, useEffectEvent } from 'react'

export function useChatRoom(roomId: string, theme: string) {
  // Effect Event: always reads latest theme, never triggers reconnect
  const onMessage = useEffectEvent((msg: string) => {
    logAnalytics('new_message', { roomId, theme })
    showNotification(msg)
  })

  useEffect(() => {
    const connection = createConnection(roomId)
    connection.on('message', onMessage)
    connection.connect()
    return () => connection.disconnect()
  }, [roomId]) // No linter warning — onMessage is an Effect Event
}

Різниця очевидна: theme читається безпосередньо в тілі Effect Event без рефів, лінтер не генерує попереджень, а перепідключення до кімнати відбувається лише при зміні roomId. Код стає чистішим і зрозумілішим.

Правила та обмеження useEffectEvent

Перш ніж використовувати useEffectEvent у проєкті, необхідно розуміти його обмеження. React накладає суворі правила на цей хук, щоб забезпечити передбачувану поведінку.

Виклик лише з useEffect. Effect Event можна викликати тільки зсередини useEffect. Виклик з обробників подій JSX, з тіла компонента або з інших хуків призведе до помилки.

Заборона передачі в інші компоненти. Функцію, створену через useEffectEvent, не можна передавати як проп дочірньому компоненту. Вона призначена виключно для локального використання всередині того ефекту, де вона потрібна.

Один Effect Event на одну логічну дію. Рекомендується створювати окремий Effect Event для кожної окремої дії (аналітика, нотифікація, логування), а не об'єднувати різну логіку в одну функцію.

Нижче наведено коректний приклад використання Effect Event для трекінгу пошукових запитів:

components/SearchTracker.tsxtsx
// Correct: Effect Event used inside useEffect
import { useEffect, useEffectEvent, useState } from 'react'

interface SearchTrackerProps {
  query: string
  userId: string
}

export function SearchTracker({ query, userId }: SearchTrackerProps) {
  const [results, setResults] = useState<string[]>([])

  // Track searches with current user context
  const onSearchComplete = useEffectEvent((resultCount: number) => {
    analytics.track('search_complete', {
      query,
      userId,       // Always latest userId
      resultCount,
      timestamp: Date.now(),
    })
  })

  useEffect(() => {
    const controller = new AbortController()
    fetchSearchResults(query, controller.signal).then((data) => {
      setResults(data)
      onSearchComplete(data.length) // Called from inside useEffect
    })
    return () => controller.abort()
  }, [query]) // userId excluded safely — lives in the Effect Event

  return <ResultsList results={results} />
}

У цьому прикладі userId може змінюватися (наприклад, при перемиканні акаунтів), але ефект пошуку перезапускається лише при зміні query. Аналітика при цьому завжди отримує актуальний userId.

Компонент Activity: збереження стану прихованих компонентів

Другий новий API — Activity — вирішує іншу проблему. У традиційному React, коли компонент прибирається з DOM (наприклад, при перемиканні вкладок), увесь його стан втрачається. Форми очищуються, скрол-позиція скидається, підписки розриваються. Activity дозволяє приховати компонент, зберігши його стан у пам'яті.

components/TabLayout.tsxtsx
// Activity preserves form state across tab switches
import { useState } from 'react'
import { Activity } from 'react'

interface Tab {
  id: string
  label: string
  content: React.ReactNode
}

export function TabLayout({ tabs }: { tabs: Tab[] }) {
  const [activeTab, setActiveTab] = useState(tabs[0].id)

  return (
    <div>
      <nav className="flex gap-2 border-b border-border">
        {tabs.map((tab) => (
          <button
            key={tab.id}
            onClick={() => setActiveTab(tab.id)}
            className={activeTab === tab.id ? 'border-b-2 border-primary' : ''}
          >
            {tab.label}
          </button>
        ))}
      </nav>
      {tabs.map((tab) => (
        <Activity key={tab.id} mode={activeTab === tab.id ? 'visible' : 'hidden'}>
          {tab.content}
        </Activity>
      ))}
    </div>
  )
}

Activity приймає проп mode з двома значеннями: 'visible' та 'hidden'. У режимі hidden компонент залишається у React-дереві, але не рендериться в DOM. Усі useState, контекст та рефи зберігаються. При поверненні до 'visible' компонент відновлюється миттєво, без повторної ініціалізації.

Передрендеринг маршрутів з Activity

Окрім збереження стану, Activity відкриває можливість попереднього рендерингу (pre-rendering) контенту у фоновому режимі. Це корисно для дешбордів, де наступний маршрут можна підготувати заздалегідь:

components/PrerenderedRoute.tsxtsx
// Pre-render the next likely route in the background
import { Activity, Suspense, use } from 'react'

interface PrerenderedRouteProps {
  isActive: boolean
  dataPromise: Promise<DashboardData>
}

export function PrerenderedRoute({ isActive, dataPromise }: PrerenderedRouteProps) {
  return (
    <Activity mode={isActive ? 'visible' : 'hidden'}>
      <Suspense fallback={<DashboardSkeleton />}>
        <DashboardContent dataPromise={dataPromise} />
      </Suspense>
    </Activity>
  )
}

function DashboardContent({ dataPromise }: { dataPromise: Promise<DashboardData> }) {
  // use() reads the cached promise — works during hidden pre-render
  const data = use(dataPromise)
  return (
    <div className="grid grid-cols-3 gap-4">
      <MetricsCard data={data.metrics} />
      <ChartPanel data={data.charts} />
      <RecentActivity items={data.activity} />
    </div>
  )
}

Хук use() читає закешований проміс навіть під час прихованого рендерингу. Коли користувач переходить на цей маршрут, дані вже готові, і компонент відображається миттєво.

Готовий до співбесід з React / Next.js?

Практикуйся з нашими інтерактивними симуляторами, flashcards та технічними тестами.

Підводний камінь: Activity та TanStack Query

При використанні Activity разом із TanStack Query виникає неочевидна проблема. Коли компонент знаходиться в режимі hidden, хуки всередині нього не виконуються. Це означає, що useQuery не ініціює запит до API, і при перемиканні на видимий режим дані можуть бути відсутні.

useQuery не виконується в прихованому Activity

Компоненти в режимі hidden не запускають свої ефекти та хуки. Якщо useQuery знаходиться всередині Activity з mode="hidden", дані не завантажуються. Необхідно виконувати prefetch на рівні батьківського компонента.

components/UserDashboard.tsxtsx
// Problem: useQuery won't fetch when Activity is hidden
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Activity } from 'react'

function UserStats() {
  // This useQuery will NOT run while hidden
  const { data } = useQuery({
    queryKey: ['user-stats'],
    queryFn: fetchUserStats,
  })
  return <StatsDisplay data={data} />
}

// Solution: prefetch data outside the Activity boundary
function DashboardWithPrefetch({ showStats }: { showStats: boolean }) {
  const queryClient = useQueryClient()

  // Prefetch at the parent level — runs regardless of Activity mode
  queryClient.ensureQueryData({
    queryKey: ['user-stats'],
    queryFn: fetchUserStats,
  })

  return (
    <Activity mode={showStats ? 'visible' : 'hidden'}>
      <UserStats />
    </Activity>
  )
}

Рішення полягає у використанні queryClient.ensureQueryData() або queryClient.prefetchQuery() на рівні батьківського компонента, який завжди залишається видимим. Таким чином, дані потраплять у кеш TanStack Query незалежно від стану Activity, а useQuery всередині прихованого компонента підхопить їх із кешу при переході в режим visible.

Поєднання useEffectEvent та Activity

Найцікавіший сценарій виникає при комбінуванні обох API. Розглянемо live-дешборд із кількома каналами WebSocket, де кожен канал працює в окремій вкладці:

components/LiveDashboard.tsxtsx
import { useEffect, useEffectEvent, useState } from 'react'
import { Activity } from 'react'

function LiveFeed({ channel, userId }: { channel: string; userId: string }) {
  const [messages, setMessages] = useState<Message[]>([])

  // Analytics tracking with latest userId — no effect re-sync
  const onNewMessage = useEffectEvent((msg: Message) => {
    analytics.track('live_message', { channel, userId })
    setMessages((prev) => [...prev, msg])
  })

  useEffect(() => {
    const ws = new WebSocket(`wss://api.example.com/${channel}`)
    ws.onmessage = (event) => {
      const msg = JSON.parse(event.data) as Message
      onNewMessage(msg)
    }
    return () => ws.close()
  }, [channel]) // Clean reconnect only when channel changes

  return <MessageList messages={messages} />
}

export function LiveDashboard({ userId }: { userId: string }) {
  const [activeChannel, setActiveChannel] = useState('general')
  const channels = ['general', 'alerts', 'metrics']

  return (
    <div>
      <nav className="flex gap-2">
        {channels.map((ch) => (
          <button key={ch} onClick={() => setActiveChannel(ch)}>
            {ch}
          </button>
        ))}
      </nav>
      {channels.map((ch) => (
        <Activity key={ch} mode={activeChannel === ch ? 'visible' : 'hidden'}>
          <LiveFeed channel={ch} userId={userId} />
        </Activity>
      ))}
    </div>
  )
}

У цьому прикладі useEffectEvent забезпечує актуальність userId в аналітиці без перепідключення WebSocket, а Activity зберігає стан повідомлень кожного каналу при перемиканні між вкладками. При переході на прихований канал WebSocket-з'єднання зупиняється (ефекти не працюють у hidden), а при поверненні — відновлюється. Історія повідомлень при цьому залишається на місці.

Поведінка ефектів у hidden-режимі

Коли Activity переходить у режим hidden, React виконує cleanup-функції всіх useEffect всередині. При поверненні в visible ефекти запускаються заново. Це означає, що WebSocket-з'єднання коректно закривається та відкривається, але стан useState зберігається.

Питання для технічних співбесід

Ці API вже з'являються на технічних співбесідах з React у 2026 році. Нижче наведено типові питання та ключові тези для відповідей.

Яку проблему вирішує useEffectEvent? Хук вирішує проблему застарілих замикань (stale closures) в обробниках подій усередині useEffect. Він дозволяє читати актуальні значення пропсів і стану без додавання їх до масиву залежностей ефекту, що запобігає небажаним перезапускам ефектів.

Чим useEffectEvent відрізняється від useCallback? useCallback повертає мемоізовану функцію, яка оновлюється при зміні залежностей і входить до реактивної системи React. useEffectEvent повертає стабільну функцію, яка завжди має доступ до актуальних значень, але ніколи не спричиняє перезапуск ефекту. Effect Event навмисно виключений із системи залежностей.

Чи можна передати Effect Event як проп дочірньому компоненту? Ні. React забороняє це. Effect Event призначений для локального використання всередині компонента, де він був створений, та може викликатися лише з useEffect.

Чим Activity відрізняється від умовного рендерингу? При умовному рендерингу ({show && <Component />}) компонент повністю розмонтується та втрачає стан. Activity з mode="hidden" прибирає компонент з DOM, але зберігає увесь його React-стан (useState, контекст, рефи). При поверненні в visible стан відновлюється без повторної ініціалізації.

Що відбувається з useEffect всередині прихованого Activity? React виконує cleanup-функції всіх ефектів при переході в hidden та перезапускає ефекти при поверненні в visible. Це забезпечує коректне управління ресурсами (підписки, WebSocket-з'єднання), при цьому стан компонента зберігається.

Як вирішити проблему з TanStack Query всередині Activity? useQuery не виконує запити у прихованому Activity. Рішення — використовувати queryClient.ensureQueryData() або queryClient.prefetchQuery() у батьківському компоненті, що знаходиться за межами Activity. Дані потраплять у кеш і будуть доступні для useQuery при переході компонента у видимий режим.

Як useEffectEvent та Activity працюють разом? useEffectEvent забезпечує актуальність значень у колбеках ефектів, а Activity зберігає стан компонентів між перемиканнями. Разом вони дозволяють створювати інтерфейси з кількома вкладками, де кожна вкладка має свій стан і підписки, які коректно зупиняються та відновлюються без втрати даних.

Висновки

useEffectEvent та Activity заповнюють важливі прогалини в API React. Перший усуває необхідність у useRef-обхідних рішеннях для застарілих замикань і робить код ефектів чистішим та передбачуванішим. Другий надає нативний механізм збереження стану прихованих компонентів, що раніше вимагало сторонніх бібліотек або складних архітектурних рішень. Обидва API знаходяться в експериментальній фазі, але їхній вплив на патерни розробки React-додатків вже відчутний. Розробникам, які готуються до технічних співбесід у 2026 році, варто не лише розуміти теорію, а й мати практичний досвід використання цих хуків у реальних сценаріях: live-дешборди, багатовкладкові інтерфейси, системи з аналітикою та фоновий передрендеринг.

Починай практикувати!

Перевір свої знання з нашими симуляторами співбесід та технічними тестами.

Теги

#react
#useEffectEvent
#activity
#react 19
#hooks
#interview

Поділитися

Пов'язані статті