React 19 useEffectEvent i Activity: nowe API i pytania rekrutacyjne 2026

Kompleksowy przewodnik po useEffectEvent i Activity w React 19.2. Rozwiazywanie stale closures, pre-rendering w tle, przyklady kodu i pytania rekrutacyjne.

Diagram architektury React 19.2 useEffectEvent i Activity API

React 19.2 wprowadza dwa nowe API, które zmieniają sposób zarządzania efektami ubocznymi i renderowaniem w tle: useEffectEvent oraz Activity. Oba rozwiązują realne problemy znane deweloperom od lat — przestarzałe domknięcia (stale closures) w hookach oraz utratę stanu komponentów przy przełączaniu widoków. W tym artykule omówiono mechanizm działania obu API, przedstawiono praktyczne przykłady kodu oraz zebrano pytania rekrutacyjne, które mogą pojawić się na rozmowach kwalifikacyjnych w 2026 roku.

React 19.2 wymagany

Hooki useEffectEvent oraz komponent Activity są dostępne od React 19.2. Przed rozpoczęciem pracy z nimi należy upewnić się, że projekt korzysta z odpowiedniej wersji: react@19.2.0 lub nowszej. Pełna dokumentacja zmian dostępna jest w oficjalnym wpisie na blogu React.

Problem przestarzałych domknięć i dotychczasowe obejścia

Jednym z najczęstszych problemów przy pracy z useEffect jest tzw. stale closure — sytuacja, w której callback efektu przechwytuje nieaktualną wartość zmiennej z momentu ostatniego renderowania. Klasycznym przykładem jest hook, który nasłuchuje wiadomości na czacie i loguje je do systemu analityki. Zmiana motywu (theme) nie powinna powodować ponownego połączenia z pokojem czatu, ale jednocześnie logowanie powinno zawsze odzwierciedlać aktualny motyw.

Przed useEffectEvent jedynym rozwiązaniem było użycie useRef jako pośrednika:

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
}

Powyższe podejście działa, lecz ma istotne wady. Po pierwsze, celowe pomijanie theme w tablicy zależności powoduje ostrzeżenie lintera react-hooks/exhaustive-deps. Po drugie, wzorzec z useRef zaciemnia intencję kodu — czytelnik musi samodzielnie wywnioskować, dlaczego dany ref istnieje i jak jest synchronizowany. W większych bazach kodu prowadzi to do błędów trudnych do wychwycenia podczas code review.

useEffectEvent — czyste rozdzielenie odpowiedzialności

Hook useEffectEvent rozwiązuje problem przestarzałych domknięć na poziomie API. Pozwala zdefiniować funkcję, która zawsze odczytuje najnowsze wartości propsów i stanu, ale nigdy nie powoduje ponownego uruchomienia efektu. Semantycznie jest to "zdarzenie wywoływane przez efekt" — nie jest to sam efekt, lecz reakcja na coś, co dzieje się wewnątrz efektu.

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
}

Różnica jest fundamentalna. Tablica zależności [roomId] jest teraz kompletna i poprawna — linter nie zgłasza żadnych ostrzeżeń. Zmienna theme jest odczytywana wewnątrz useEffectEvent, więc zawsze ma aktualną wartość bez konieczności stosowania refów. Kod jasno komunikuje intencję: połączenie zależy od roomId, a logowanie analityki to zdarzenie, które korzysta z bieżącego kontekstu.

Zasady i ograniczenia useEffectEvent

Hook useEffectEvent podlega ścisłym regułom, których naruszenie prowadzi do błędów w czasie wykonania lub ostrzeżeń lintera. Znajomość tych reguł jest kluczowa zarówno w codziennej pracy, jak i na rozmowach rekrutacyjnych.

Funkcja zwrócona przez useEffectEvent może być wywoływana wyłącznie z wnętrza useEffect. Nie wolno przekazywać jej jako propsu do komponentów potomnych ani wywoływać w handlerach zdarzeń DOM. Jest to celowe ograniczenie — API zostało zaprojektowane wyłącznie do rozwiązywania problemu przestarzałych domknięć w efektach.

Poniższy przykład ilustruje poprawne użycie — onSearchComplete jest wywoływany wewnątrz useEffect, a zmienna userId jest zawsze aktualna:

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} />
}
Czego nie wolno robić z useEffectEvent

Funkcji zwróconej przez useEffectEvent nie wolno przekazywać jako prop do komponentów potomnych, wywoływać w obsłudze zdarzeń onClick/onChange ani używać poza kontekstem useEffect. Naruszenie tych reguł prowadzi do nieprzewidywalnego zachowania aplikacji. Pełna specyfikacja ograniczeń dostępna jest w dokumentacji React.

Komponent Activity — renderowanie w tle bez utraty stanu

Drugim nowym API w React 19.2 jest komponent Activity (wcześniej znany pod roboczą nazwą Offscreen). Rozwiązuje on problem, z którym deweloperzy borykają się od lat: jak ukryć fragment interfejsu bez odmontowywania go z drzewa komponentów i utraty wewnętrznego stanu.

Typowy scenariusz to układ z zakładkami, w którym użytkownik wypełnia formularz w jednej zakładce, przełącza się na drugą, a po powrocie oczekuje, że formularz zachował wprowadzone dane. Bez Activity realizacja tego wymaga ręcznego zarządzania stanem na poziomie rodzica lub użycia CSS display: none, co nie jest optymalne z perspektywy wydajności.

Tryby Activity: visible i hidden

Komponent Activity przyjmuje prop mode z dwiema wartościami: 'visible' oraz 'hidden'. W trybie 'visible' drzewo potomne renderuje się i zachowuje normalnie. W trybie 'hidden' React zachowuje cały stan komponentów w pamięci, ale wstrzymuje efekty uboczne — useEffect cleanup uruchamia się przy przejściu do 'hidden', a setup ponownie przy powrocie do 'visible'.

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>
  )
}

Warto zwrócić uwagę na kluczowy szczegół implementacyjny: każda zakładka jest renderowana od razu, ale tylko aktywna ma tryb 'visible'. Przełączanie zakładek zmienia jedynie prop mode, nie odmontowuje komponentów. Dzięki temu stan formularzy, pozycja scrolla i lokalne zmienne stanu są zachowane.

Komponent Activity umożliwia również pre-rendering — wstępne renderowanie tras lub widoków, do których użytkownik prawdopodobnie nawiguje w następnym kroku:

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>
  )
}

W tym wzorcu Suspense współpracuje z Activity — obietnica (promise) jest rozwiązywana w tle, a gdy użytkownik przejdzie do danej trasy, treść wyświetla się natychmiast, bez widocznego opóźnienia.

Gotowy na rozmowy o React / Next.js?

Ćwicz z naszymi interaktywnymi symulatorami, flashcards i testami technicznymi.

Pułapka z TanStack Query i Activity

Integracja Activity z bibliotekami zarządzania stanem serwerowym, takimi jak TanStack Query, wymaga szczególnej uwagi. Gdy komponent znajduje się wewnątrz Activity w trybie 'hidden', React wstrzymuje jego efekty uboczne. Oznacza to, że useQuery nie uruchomi zapytania sieciowego, ponieważ wewnętrznie korzysta z useEffect do inicjalizacji fetchingu.

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>
  )
}

Rozwiązaniem jest przeniesienie logiki prefetchingu na poziom komponentu nadrzędnego za pomocą queryClient.ensureQueryData. Ta metoda działa poza granicą Activity, więc dane są pobierane niezależnie od trybu. Gdy Activity przejdzie do 'visible', useQuery wewnątrz UserStats odczyta dane z cache TanStack Query bez dodatkowego zapytania sieciowego. Więcej o zaawansowanych wzorcach TanStack Query można przeczytać w sekcji pytania rekrutacyjne React Query.

ensureQueryData vs prefetchQuery

Metoda ensureQueryData pobiera dane tylko wtedy, gdy nie ma ich jeszcze w cache (lub są przestarzałe zgodnie z staleTime). W odróżnieniu od prefetchQuery, zwraca ona Promise z danymi, co pozwala na ich użycie również w logice rodzica. W kontekście Activity oba podejścia są poprawne, ale ensureQueryData jest bardziej wydajne, ponieważ nie wykonuje zbędnych zapytań.

Połączenie useEffectEvent i Activity — praktyczny wzorzec

Oba API najlepiej prezentują swoje możliwości, gdy są używane razem. Poniższy przykład przedstawia dashboard z kanałami na żywo — każdy kanał utrzymuje połączenie WebSocket, a przełączanie między kanałami nie powoduje utraty wiadomości ani niepotrzebnego reconnectu:

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>
  )
}

W tym wzorcu zachodzą trzy kluczowe mechanizmy jednocześnie. Po pierwsze, useEffectEvent gwarantuje, że userId w logowaniu analityki jest zawsze aktualny, bez ponownego otwierania połączenia WebSocket. Po drugie, Activity zachowuje stan wiadomości w ukrytych kanałach — przełączenie z powrotem na kanał natychmiast wyświetla pełną historię. Po trzecie, cleanup efektu (zamknięcie WebSocket) uruchamia się automatycznie, gdy Activity przechodzi do trybu 'hidden', co oszczędza zasoby sieciowe. Przy powrocie do 'visible' efekt uruchamia się ponownie, nawiązując świeże połączenie.

Pytania rekrutacyjne — useEffectEvent i Activity

Poniższe pytania regularnie pojawiają się na rozmowach kwalifikacyjnych w 2026 roku. Kandydaci powinni znać nie tylko odpowiedzi, ale również potrafić uzasadnić je przykładami kodu.

1. Czym różni się useEffectEvent od zwykłego callbacka przekazanego do useEffect? Zwykły callback przechwytuje wartości z momentu ostatniego renderowania, w którym efekt się uruchomił. useEffectEvent zawsze odczytuje najnowsze wartości propsów i stanu, nie będąc częścią tablicy zależności efektu.

2. Dlaczego nie wolno przekazywać funkcji z useEffectEvent jako propsu? Funkcja z useEffectEvent jest powiązana z cyklem życia efektu — jest stabilna referencyjnie, ale jej semantyka zakłada wywołanie wyłącznie z wnętrza useEffect. Przekazanie jej jako propsu złamałoby tę gwarancję i mogłoby prowadzić do nieprzewidywalnego zachowania.

3. Co dzieje się z useEffect wewnątrz Activity w trybie hidden? React uruchamia cleanup efektów przy przejściu do 'hidden' i ponownie setup przy powrocie do 'visible'. Stan komponentu (useState, useRef) jest zachowany w pamięci, ale efekty uboczne są wstrzymane.

4. Jak rozwiązać problem z useQuery wewnątrz Activity? Należy przenieść prefetching danych na poziom komponentu nadrzędnego za pomocą queryClient.ensureQueryData lub queryClient.prefetchQuery. Te metody działają poza granicą Activity i zapełniają cache, z którego useQuery odczyta dane po przejściu do trybu 'visible'.

5. Czy Activity zastępuje React.memo lub useMemo? Nie. Activity rozwiązuje problem zachowania stanu ukrytych komponentów, podczas gdy React.memo i useMemo optymalizują ponowne renderowanie. Mogą być stosowane razem — np. komponent wewnątrz Activity może używać useMemo do optymalizacji obliczeń.

6. Podaj scenariusz, w którym useEffectEvent i Activity współpracują. Dashboard z wieloma kanałami WebSocket, gdzie przełączanie między kanałami nie powoduje utraty wiadomości. Activity zachowuje stan wiadomości, useEffectEvent zapewnia aktualne dane w logowaniu analityki bez ponownego nawiązywania połączeń.

7. Co się stanie, jeśli wywołasz funkcję z useEffectEvent poza useEffect? React zgłosi błąd lub ostrzeżenie w trybie deweloperskim. API zostało zaprojektowane wyłącznie do użytku wewnątrz efektów i naruszenie tej reguły jest traktowane jako błąd programistyczny.

Więcej pytań rekrutacyjnych dotyczących hooków React dostępnych jest w sekcji pytania rekrutacyjne React Hooks.

Podsumowanie

useEffectEvent i Activity to dwa uzupełniające się API, które adresują realne problemy w aplikacjach React. Pierwsze eliminuje przestarzałe domknięcia w efektach bez konieczności stosowania wzorca z useRef, drugie umożliwia zachowanie stanu komponentów w tle i pre-rendering tras. W połączeniu dają potężny zestaw narzędzi do budowy responsywnych interfejsów z zachowaniem pełnej kontroli nad cyklem życia komponentów i efektów ubocznych. Znajomość obu API, ich ograniczeń oraz wzorców integracji z bibliotekami takimi jak TanStack Query stanowi istotny element przygotowania do rozmów rekrutacyjnych na stanowiska React w 2026 roku.

Zacznij ćwiczyć!

Sprawdź swoją wiedzę z naszymi symulatorami rozmów i testami technicznymi.

Tagi

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

Udostępnij

Powiązane artykuły