React 19 useEffectEvent and Activity: New APIs and Interview Questions 2026

Deep dive into React 19.2 useEffectEvent and Activity component. Stale closure solutions, background pre-rendering, code examples, and interview questions.

Diagram of React 19.2 useEffectEvent and Activity API architecture

React 19.2 introduced two APIs that address long-standing pain points: useEffectEvent eliminates stale closures in effects, and <Activity> enables background pre-rendering with state preservation. Both shipped in October 2025 and are already changing how production React applications handle side effects and navigation.

React 19.2 Minimum

useEffectEvent and Activity require React 19.2 or later. Update with npm install react@latest react-dom@latest. The ESLint plugin eslint-plugin-react-hooks@6+ adds native support for useEffectEvent in dependency arrays.

What useEffectEvent Solves: The Stale Closure Problem

Every React developer has encountered stale closures. An effect captures a value at render time, and when that value changes, the effect still references the old one. The classic workaround involved useRef to hold a mutable reference — functional but verbose and invisible to the linter.

Consider a chat application that logs analytics when a new message arrives. The log should include the current theme, but theme changes should not reconnect the chat:

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
}

This works, but the linter flags theme as a missing dependency. Suppressing the warning hides potential bugs elsewhere. The useRef dance also obscures intent: a new developer reading this code has to mentally trace why theme sits in a ref instead of the dependency array.

useEffectEvent: Separating Reactive from Non-Reactive Logic

The useEffectEvent hook creates a stable function that always reads the latest props and state, without triggering effect re-synchronization. It replaces the useRef pattern with a declarative API that the linter understands natively.

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
}

The effect only re-runs when roomId changes. The onMessage callback always sees the current theme without being listed as a dependency. The official React documentation explicitly states that Effect Events must only be called from inside effects — never during rendering or passed to child components.

useEffectEvent Rules and Constraints

Three rules govern useEffectEvent:

  1. Call it at the top level of a component or custom hook — never inside loops or conditions
  2. Only call the returned function from inside useEffect or another Effect Event
  3. Never pass it as a prop or return it from a hook for external consumption

Breaking rule 2 causes subtle bugs: the function identity changes every render, so storing it in a ref or passing it down defeats the purpose. The eslint-plugin-react-hooks@6+ enforces these constraints automatically.

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} />
}
When Not to Use useEffectEvent

useEffectEvent is not an escape hatch to silence the dependency linter. If a value genuinely controls when an effect should re-run, it belongs in the dependency array. Only extract logic into an Effect Event when it represents a side action (logging, notification, analytics) that reads reactive values without needing to re-trigger the effect.

The Activity Component: Background Pre-Rendering with State Preservation

The <Activity> component (formerly called "Offscreen") controls whether its children are visible or hidden. Unlike conditional rendering which destroys state, or CSS display: none which keeps effects running, <Activity> preserves state while cleaning up effects and deferring updates.

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

A user filling out a form in the "Profile" tab can switch to "Settings" and back without losing input. The hidden tab's effects (timers, subscriptions, data fetching) are cleaned up, freeing resources. When the tab becomes visible again, effects remount and state is restored instantly.

Ready to ace your React / Next.js interviews?

Practice with our interactive simulators, flashcards, and technical tests.

Activity Modes: Visible vs Hidden Under the Hood

The <Activity> component accepts a mode prop with two values:

| Behavior | mode="visible" | mode="hidden" | |----------|-------------------|------------------| | DOM rendering | Normal | display: none via CSS | | Component state | Active | Preserved in memory | | Effects (useEffect) | Mounted | Cleaned up | | Update priority | Normal | Deferred to idle | | Pre-rendering | N/A | Renders at low priority |

When a component starts hidden (initial render with mode="hidden"), React pre-renders it at low priority without mounting effects. This enables instant navigation: the target page is already rendered in the background when the user clicks.

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

The hidden <Activity> pre-renders the dashboard at idle priority. Combined with Suspense and the use API, data fetching happens in the background. When isActive flips to true, the content appears without any loading spinner.

Activity and TanStack Query: The Cache Gotcha

A common pitfall with <Activity> involves TanStack Query. Since useQuery relies on useEffect internally, queries inside a hidden <Activity> will not execute — the effect is unmounted.

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

The fix is straightforward: move data prefetching to a parent component or use queryClient.ensureQueryData outside the <Activity> boundary. The cached data will be available when the hidden component becomes visible.

Memory Trade-off

Activity trades memory for speed. Each hidden component tree stays in memory with its full DOM. For applications with many hidden routes, monitor memory usage. The React team is exploring automatic eviction of least-recently-used hidden Activities in future releases.

Combining useEffectEvent and Activity

Both APIs complement each other in real-world navigation patterns. A common scenario: a tabbed dashboard where each tab has WebSocket subscriptions and analytics tracking.

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

When switching channels, the hidden feed's WebSocket disconnects (effect cleanup via <Activity>). The message history survives in state. The onNewMessage Effect Event ensures analytics always reference the current userId without forcing WebSocket reconnections.

Interview Questions: useEffectEvent and Activity

These questions test understanding of the new APIs and their interaction with React's rendering model. They are increasingly common in React interviews at companies using React 19.2.

Q1: What problem does useEffectEvent solve that useCallback cannot?

useCallback creates a memoized function, but it still needs to be listed in the effect's dependency array. If any of its own dependencies change, the callback changes, which re-triggers the effect. useEffectEvent creates a function that always reads the latest values without being a dependency — the effect never re-runs because of it. This separation is impossible with useCallback alone.

Q2: Can an Effect Event be passed as a prop to a child component?

No. Effect Events are designed to be called only from inside useEffect or other Effect Events. Their identity changes every render, so passing them as props would cause unnecessary re-renders and break the mental model. The ESLint plugin enforces this rule.

Q3: How does Activity differ from conditional rendering and CSS display:none?

Conditional rendering ({show && <Component />}) unmounts the component entirely — state is destroyed. CSS display: none hides visually but keeps all effects running, wasting resources. <Activity mode="hidden"> preserves state, cleans up effects, defers updates to idle priority, and can pre-render content in the background.

Q4: What happens to useEffect inside a hidden Activity?

When an <Activity> transitions to mode="hidden", React runs all effect cleanup functions (the return value of useEffect). No new effects mount while hidden. When the component becomes visible again, effects remount with the preserved state. This is why data fetching libraries that rely on useEffect need prefetching strategies outside the Activity boundary.

Q5: How would you pre-render a route with Activity and Suspense?

Wrap the route in <Activity mode="hidden"> with a <Suspense> boundary inside. Use the use() API or a Suspense-enabled data source for fetching. React renders the hidden tree at low priority, resolving the Suspense boundary in the background. When the user navigates and mode flips to "visible", the fully rendered content appears instantly without a loading state.

Q6: Is useEffectEvent a replacement for the exhaustive-deps lint rule?

No. The exhaustive-deps rule remains critical for catching genuine missing dependencies. useEffectEvent handles a specific case: logic that reads reactive values but should not control when the effect re-runs (analytics, notifications, logging). Using it to suppress all dependency warnings hides bugs and defeats its purpose.

Conclusion

  • useEffectEvent replaces the useRef workaround for stale closures in effects, with native linter support in eslint-plugin-react-hooks@6+
  • Effect Events always read the latest props and state without triggering effect re-synchronization — use them for analytics, logging, and notification callbacks
  • <Activity> preserves component state while cleaning up effects, offering a middle ground between conditional rendering and CSS hiding
  • Hidden Activities pre-render at idle priority, enabling instant navigation when combined with Suspense and the use API
  • TanStack Query and other effect-based libraries need prefetching outside Activity boundaries since useEffect does not run in hidden mode
  • Both APIs ship in React 19.2 — update ESLint and React together for full tooling support

Start practicing!

Test your knowledge with our interview simulators and technical tests.

Tags

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

Share

Related articles