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
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:
Preserves the Fiber Tree: React's internal representation of your component stays in memory, so all your useState and useReducer state is preserved
Unmounts Effects: All useEffect cleanup functions are called, stopping subscriptions, timers, and network requests
Hides the DOM: Sets display: none on the container, so the browser's layout engine doesn't process it
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:
useEffectEvent creates a stable function reference that doesn't change between renders
The function always sees the latest values of theme and other variables
The effect only re-runs when roomId changes
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.



