Skip to main content

Command Palette

Search for a command to run...

React 19.2: Activity Component and useEffectEvent Hook

Updated
9 min read
React 19.2: Activity Component and useEffectEvent Hook

React 19.2, released on October 1, 2025, introduced two APIs that solve problems developers have been wrestling with since hooks were first introduced: the Activity component and the useEffectEvent hook. This guide explores what these APIs do, why they matter, and how to use them effectively in production. We also cover a critical security vulnerability (CVE-2025-55182) that was patched in React 19.2.1.

Table of Contents

  1. The Activity Component

  2. The useEffectEvent Hook

  3. Security Update: CVE-2025-55182

  4. Migration Guide

  5. Resources

The Activity Component

The Problem: The Tab State Dilemma

React developers have faced an impossible choice when building tabbed interfaces, wizards, or multi-step forms.

Option 1: Conditional Rendering

function App() {
  const [activeTab, setActiveTab] = useState('home');

  return (
    <div>
      <TabButtons activeTab={activeTab} onChange={setActiveTab} />
      {activeTab === 'home' && <HomeTab />}
      {activeTab === 'settings' && <SettingsTab />}
      {activeTab === 'profile' && <ProfileTab />}
    </div>
  );
}

The problem: Every time you switch tabs, the inactive component is completely destroyed. Form inputs are cleared, scroll positions are lost, and network requests are cancelled. Users type half a message in Settings, switch to Home, then come back to find their work gone.

Option 2: CSS Hiding

function App() {
  const [activeTab, setActiveTab] = useState('home');

  return (
    <div>
      <TabButtons activeTab={activeTab} onChange={setActiveTab} />
      <div style={{ display: activeTab === 'home' ? 'block' : 'none' }}>
        <HomeTab />
      </div>
      <div style={{ display: activeTab === 'settings' ? 'block' : 'none' }}>
        <SettingsTab />
      </div>
      <div style={{ display: activeTab === 'profile' ? 'block' : 'none' }}>
        <ProfileTab />
      </div>
    </div>
  );
}

The problem: All tabs remain mounted and their effects keep running. Hidden video players continue buffering, hidden dashboards keep polling APIs, and hidden forms keep validating. The browser's layout engine has to manage thousands of invisible DOM nodes. Performance degrades significantly.

The Solution: Activity Component

React 19.2's Activity component gives you the best of both approaches.

import { Activity, useState } from 'react';

function App() {
  const [activeTab, setActiveTab] = useState('home');

  return (
    <div>
      <TabButtons activeTab={activeTab} onChange={setActiveTab} />

      <Activity mode={activeTab === 'home' ? 'visible' : 'hidden'}>
        <HomeTab />
      </Activity>

      <Activity mode={activeTab === 'settings' ? 'visible' : 'hidden'}>
        <SettingsTab />
      </Activity>

      <Activity mode={activeTab === 'profile' ? 'visible' : 'hidden'}>
        <ProfileTab />
      </Activity>
    </div>
  );
}

How Activity Works

When you set an Activity to mode="hidden", React performs several operations:

  1. Preserves the Fiber Tree: React's internal representation of your component stays in memory, so all your useState and useReducer state is preserved

  2. Unmounts Effects: All useEffect cleanup functions are called, stopping subscriptions, timers, and network requests

  3. Hides the DOM: Sets display: none on the container, so the browser's layout engine doesn't process it

  4. Defers Updates: Any state updates that happen while hidden are batched and applied at low priority

Lifecycle comparison:

Traditional Conditional Rendering:

Activity Component:

The key difference: Activity calls cleanup functions but doesn't destroy the Fiber nodes or state.

Real-World Example: Music Player with Tabs

import { Activity, useState, useEffect } from 'react';

function MusicPlayer({ songUrl }) {
  const [isPlaying, setIsPlaying] = useState(false);
  const [currentTime, setCurrentTime] = useState(0);

  useEffect(() => {
    if (!isPlaying) return;

    const audio = new Audio(songUrl);
    audio.currentTime = currentTime;
    audio.play();

    const interval = setInterval(() => {
      setCurrentTime(audio.currentTime);
    }, 100);

    return () => {
      clearInterval(interval);
      audio.pause();
    };
  }, [isPlaying, songUrl, currentTime]);

  return (
    <div>
      <h2>Now Playing</h2>
      <p>Time: {currentTime.toFixed(2)}s</p>
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
    </div>
  );
}

function App() {
  const [tab, setTab] = useState('player');

  return (
    <div>
      <nav>
        <button onClick={() => setTab('player')}>Player</button>
        <button onClick={() => setTab('library')}>Library</button>
      </nav>

      <Activity mode={tab === 'player' ? 'visible' : 'hidden'}>
        <MusicPlayer songUrl="/song.mp3" />
      </Activity>

      <Activity mode={tab === 'library' ? 'visible' : 'hidden'}>
        <Library />
      </Activity>
    </div>
  );
}

When you switch from Player to Library, the audio stops playing because the effect cleanup runs. The currentTime state is preserved at, for example, 1:32. When you switch back to Player, the audio starts from 1:32 because the state was preserved.

Pre-rendering: Load Before You Need It

One of the most powerful features of Activity is the ability to pre-render components while they're hidden.

function Dashboard() {
  const [activeSection, setActiveSection] = useState('overview');

  return (
    <div>
      <SectionTabs active={activeSection} onChange={setActiveSection} />

      <Activity mode={activeSection === 'overview' ? 'visible' : 'hidden'}>
        <Overview />
      </Activity>

      <Activity mode={activeSection === 'analytics' ? 'visible' : 'hidden'}>
        <Analytics />
      </Activity>

      <Activity mode={activeSection === 'reports' ? 'visible' : 'hidden'}>
        <Reports />
      </Activity>
    </div>
  );
}

function Analytics() {
  const data = use(fetchAnalyticsData());

  return <AnalyticsChart data={data} />;
}

By the time users click the Analytics tab, the data is already loaded and rendered. The transition feels instant.

Important: DOM Side Effects

Most React components work perfectly with Activity, but there's one edge case to watch for: DOM elements with persistent side effects.

function VideoPlayer({ src }) {
  return (
    <Activity mode={isVisible ? 'visible' : 'hidden'}>
      <video src={src} autoPlay />
    </Activity>
  );
}

Problem: When you hide this component, the video tag gets display: none, but the video keeps playing. You can hear the audio even though you can't see it.

Solution: Add an effect with proper cleanup

function VideoPlayer({ src }) {
  const videoRef = useRef(null);

  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;

    video.play();

    return () => {
      video.pause();
    };
  }, []);

  return <video ref={videoRef} src={src} />;
}

Other DOM elements that need this treatment include: audio, iframe, Canvas animations, and WebSocket connections.

Memory Considerations

Activity trades memory for speed. A hidden component stays in memory at roughly 2x the cost of the visible version (Fiber tree + DOM). For most applications, this is acceptable. A few hidden tabs use negligible memory on modern devices.

However, if you're building something with dozens of potential activities, the React team is exploring automatic state destruction for least-recently-used hidden activities.

Current recommendation:

<Activity mode={tab === 'home' ? 'visible' : 'hidden'}>
  <HomeTab />
</Activity>

{isModalOpen && <Modal />}

Use Activity for components the user frequently switches between. Use conditional rendering for components the user is unlikely to return to soon, such as closed modals.

The useEffectEvent Hook

The Problem: Dependency Array Issues

Here's a scenario every React developer has faced:

function ChatRoom({ roomId, theme }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();

    connection.on('connected', () => {
      showNotification('Connected!', theme);
    });

    return () => connection.disconnect();
  }, [roomId, theme]);
}

The problem: You want the effect to reconnect when roomId changes, but not when theme changes. However, ESLint's exhaustive-deps rule correctly requires you to include theme in the dependency array.

If you include theme, the effect tears down and reconnects the entire WebSocket connection just because the user switched from light mode to dark mode. That's wasteful and causes flickering.

The Old Workaround: useRef Pattern

Before useEffectEvent, developers used this pattern:

function ChatRoom({ roomId, theme }) {
  const themeRef = useRef(theme);

  useEffect(() => {
    themeRef.current = theme;
  });

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();

    connection.on('connected', () => {
      showNotification('Connected!', themeRef.current);
    });

    return () => connection.disconnect();
  }, [roomId]);
}

This works, but it's verbose, error-prone, and the linter can't help you. Plus, it's not obvious to other developers what's happening.

The Solution: useEffectEvent

React 19.2 introduces useEffectEvent to handle this pattern cleanly:

import { useEffect, useEffectEvent } from 'react';

function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    showNotification('Connected!', theme);
  });

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();

    connection.on('connected', () => {
      onConnected();
    });

    return () => connection.disconnect();
  }, [roomId]);
}

How it works:

  1. useEffectEvent creates a stable function reference that doesn't change between renders

  2. The function always sees the latest values of theme and other variables

  3. The effect only re-runs when roomId changes

  4. ESLint's React Hooks plugin (v6+) understands useEffectEvent and won't complain

Real-World Example: Analytics Logger

A common use case is logging events that need current user state but shouldn't trigger effect re-runs:

function Page({ url }) {
  const { items } = useContext(ShoppingCartContext);
  const numberOfItems = items.length;

  const onNavigate = useEffectEvent((visitedUrl) => {
    logVisit(visitedUrl, numberOfItems);
  });

  useEffect(() => {
    onNavigate(url);
  }, [url]);
}

The effect only re-runs when URL changes, but it always logs the current cart count.

Example: Timers and Intervals

Another classic use case is intervals that need to read current state:

function Counter({ incrementBy }) {
  const [count, setCount] = useState(0);

  const onTick = useEffectEvent(() => {
    setCount(c => c + incrementBy);
  });

  useEffect(() => {
    const id = setInterval(() => {
      onTick();
    }, 1000);

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

  return (
    <div>
      <h2>Count: {count}</h2>
      <p>Incrementing by: {incrementBy}</p>
    </div>
  );
}

The interval never restarts, but it always uses the current incrementBy value.

Creating Reusable Hooks

useEffectEvent is particularly useful when building custom hooks:

function useInterval(callback, delay) {
  const onTick = useEffectEvent(callback);

  useEffect(() => {
    if (delay === null) return;

    const id = setInterval(() => {
      onTick();
    }, delay);

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

function App() {
  const [count, setCount] = useState(0);
  const [incrementBy, setIncrementBy] = useState(1);

  useInterval(() => {
    setCount(c => c + incrementBy);
  }, 1000);

  return (
    <div>
      <h2>Count: {count}</h2>
      <select 
        value={incrementBy} 
        onChange={e => setIncrementBy(Number(e.target.value))}
      >
        <option value={1}>+1</option>
        <option value={5}>+5</option>
        <option value={10}>+10</option>
      </select>
    </div>
  );
}

Important Rules and Limitations

Only call from effects or other Effect Events:

function Component() {
  const onEvent = useEffectEvent(() => {
    console.log('Event fired');
  });

  useEffect(() => {
    const subscription = subscribe(onEvent);
    return () => subscription.unsubscribe();
  }, []);

  return <div>Component</div>;
}

Do not call useEffectEvent during render or pass it as a prop to DOM elements.

Do not use to hide dependencies:

useEffect(() => {
  saveData(data);
}, [data]);

If data changes should trigger the effect, keep it in the dependency array. useEffectEvent is for reading values that shouldn't trigger re-runs.

Effect Events don't have stable identity:

Do not pass Effect Events to child components or compare them in useEffect. They are meant to be called only from within effects.

When to Use useEffectEvent

Good use cases:

  • Reading latest props or state in event handlers inside effects

  • Logging with current user context

  • Notifications that need current theme or settings

  • Timers or intervals that need current values

  • Cleanup functions that need current state

Bad use cases:

  • Avoiding legitimate reactive dependencies

  • Replacing useCallback for child component props

  • Hiding bugs in dependency arrays

  • Event handlers that should be passed to DOM elements

Migration Guide

Adopting Activity in Existing Apps

Step 1: Identify candidates

Look for components that:

  • Are frequently shown or hidden (tabs, modals, sidebars)

  • Have expensive setup or teardown (WebSocket connections, subscriptions)

  • Contain user input that should be preserved (forms, text editors)

Step 2: Test incrementally

{activeTab === 'settings' && <SettingsTab />}

becomes

<Activity mode={activeTab === 'settings' ? 'visible' : 'hidden'}>
  <SettingsTab />
</Activity>

Step 3: Audit effect cleanup

Make sure your components have proper cleanup:

useEffect(() => {
  const subscription = subscribe();

  return () => {
    subscription.unsubscribe();
  };
}, []);

Enable StrictMode to catch missing cleanup functions.

Adopting useEffectEvent in Existing Apps

Step 1: Find patterns where it helps

Search your codebase for:

const someRef = useRef(someValue);
useEffect(() => { 
  someRef.current = someValue; 
});

or

useEffect(() => {

}, []);

Step 2: Refactor to useEffectEvent

const latestCallback = useRef(callback);
useEffect(() => { 
  latestCallback.current = callback; 
});
useEffect(() => {
  const id = setInterval(() => latestCallback.current(), 1000);
  return () => clearInterval(id);
}, []);

becomes

const onTick = useEffectEvent(callback);
useEffect(() => {
  const id = setInterval(onTick, 1000);
  return () => clearInterval(id);
}, []);

Conclusion

React 19.2's Activity component and useEffectEvent hook solve two fundamental problems that have plagued React developers since the beginning. Activity eliminates the false choice between state preservation and performance when hiding UI. useEffectEvent provides a clean, linter-friendly way to access latest values without unnecessary effect re-runs.

Resources

More from this blog

Y

Yogesh Bhawsar

5 posts

Technical Lead with strong full-stack experience in Node.js and React, building scalable web products with clean architecture and AI-driven solutions.

Get in touch: yogesh@bhawsar.dev

React 19.2: Activity Component and useEffectEvent Hook