·Updated

React 19.2: Upgrade Guide

React 19.2 upgrade guide updated for critical security vulnerabilities (CVE-2025-55182). Learn about Activity, useEffectEvent, cacheSignal, Partial Pre-rendering, and how to patch safely.

Updated January 2026: This guide now includes critical security patches for CVE-2025-55182 (React2Shell) and related vulnerabilities. If you're running React 19.0.0 through 19.2.2, update immediately.

React 19.2 marks the third major release within a year, bringing significant new features and improvements to the React ecosystem. This release introduces:

  1. The <Activity> component to manage visibility and preserve state
  2. The useEffectEvent hook for stable event handlers in effects
  3. The cacheSignal API for React Server Components
  4. Partial Pre-rendering for hybrid static/dynamic rendering
  5. Chrome DevTools Performance Tracks
  6. SSR streaming improvements

In general, there are no breaking changes in this release, but there are stricter ESLint rules and critical security patches you need to apply.

Critical Security Update (December 2025)

In December 2025, multiple critical vulnerabilities were disclosed affecting React Server Components. If you're using React 19.0.0 through 19.2.2, your application may be vulnerable to remote code execution.

Vulnerabilities Summary

CVESeverityCVSSImpact
CVE-2025-55182Critical10.0Remote Code Execution (React2Shell)
CVE-2025-55184High7.5Denial of Service
CVE-2025-55183Medium5.3Source Code Exposure
CVE-2025-67779High7.5DoS (incomplete fix)

Who Is Affected

You're affected if your application:

  • Uses React Server Components (RSC)
  • Uses any framework supporting RSC: Next.js, React Router, Waku, or similar
  • Includes react-server-dom-webpack, react-server-dom-parcel, or react-server-dom-turbopack

Default create-next-app configurations are vulnerable. Even apps without explicit Server Functions are at risk if they support RSC.

You're NOT affected if:

  • Your app is client-only (no server rendering)
  • You don't use any framework or bundler supporting RSC

How to Check Your Version

For Next.js users: Next.js bundles its own forked version of React internally. The version shown in npm list react is not what Next.js actually uses. What matters is your Next.js version, not your react package version.

Check your Next.js version:

npm list next

If your Next.js version is below the patched versions in the table above (14.2.35, 15.0.7, 15.1.11, 15.2.8, or 16.0.10), you need to update.

For other frameworks (React Router, Waku, etc.): Check your react-server-dom-* packages:

npm list react-server-dom-webpack react-server-dom-parcel react-server-dom-turbopack

Vulnerable output (needs update):

└── react-server-dom-webpack@19.2.0

Patched output (safe):

└── react-server-dom-webpack@19.2.3

If you see versions 19.0.0 through 19.2.2 on any react-server-dom-* package, you need to update.

Patched Versions

React packages: 19.0.3, 19.1.4, or 19.2.3

Next.js (by version line):

Your VersionUpdate To
13.3.x, 13.4.x, 13.5.x, 14.x14.2.35
15.0.x15.0.7
15.1.x15.1.11
15.2.x15.2.8
16.0.x16.0.10

How to Patch

For Next.js users (most common): Update Next.js to the patched version for your version line. This is all you need to do since Next.js bundles its own React.

# For Next.js 16.x
npm install next@16.0.10
# For Next.js 15.2.x
npm install next@15.2.8
# For Next.js 15.1.x
npm install next@15.1.11
# For Next.js 14.x or 13.3+
npm install next@14.2.35

Or use the automated fix that detects your version:

npx fix-react2shell-next

For other frameworks (React Router, Waku, etc.): Update the React packages directly:

npm install react@19.2.3 react-dom@19.2.3
npm install react-server-dom-webpack@19.2.3

After Patching

Rotate all application secrets. The RCE vulnerability means attackers may have accessed your server. Treat any secrets in environment variables or hardcoded in Server Functions as potentially compromised.

WAF rules are not sufficient protection. Attackers can modify payloads to bypass signature-based blocking. The only reliable fix is upgrading.

For full details, see the React Security Advisory and follow-up disclosure.

Activity Component

The <Activity> component controls the visibility of a component tree while preserving its state. This is different from conditional rendering, which destroys and recreates components.

Before React 19.2:

{currentTab === 'home' && <HomePage />}

With React 19.2:

<Activity mode={currentTab === 'home' ? 'visible' : 'hidden'}>
<HomePage />
</Activity>

The key difference: <Activity> preserves component state when hidden. The ternary approach unmounts the component entirely, losing all state.

When to Use Activity

Use <Activity> when you want to:

  • Pre-render likely destinations: Load the next tab's content in the background while the user views the current tab
  • Preserve form state: Keep a partially-filled form intact when the user navigates away and back
  • Improve perceived performance: Hide components without the cost of remounting them

How Hidden Mode Works

When mode="hidden":

  • Children are hidden from the DOM
  • Effects are unmounted (cleanup runs)
  • Updates are deferred until React has nothing else to process
  • State is preserved in memory

When mode="visible":

  • Children render normally
  • Effects mount
  • Updates process at normal priority

Practical Example: Tabbed Interface

import { Activity } from 'react';
function TabbedDashboard() {
const [activeTab, setActiveTab] = useState('overview');
return (
<div>
<nav>
<button onClick={() => setActiveTab('overview')}>Overview</button>
<button onClick={() => setActiveTab('analytics')}>Analytics</button>
<button onClick={() => setActiveTab('settings')}>Settings</button>
</nav>
<Activity mode={activeTab === 'overview' ? 'visible' : 'hidden'}>
<OverviewTab />
</Activity>
<Activity mode={activeTab === 'analytics' ? 'visible' : 'hidden'}>
<AnalyticsTab />
</Activity>
<Activity mode={activeTab === 'settings' ? 'visible' : 'hidden'}>
<SettingsTab />
</Activity>
</div>
);
}

Each tab maintains its state. If a user starts filling out a form in Settings, switches to Analytics, then returns to Settings, their form data is still there.

For comprehensive documentation, see the official React Activity reference.

useEffectEvent Hook

The useEffectEvent hook creates a stable function that always accesses the latest props and state without triggering effect re-runs.

The Problem It Solves

Consider this common pattern:

function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => {
showNotification('Connected!', theme);
});
connection.connect();
return () => connection.disconnect();
}, [roomId, theme]); // theme causes reconnection!
}

The effect depends on theme only for the notification, but any theme change reconnects the chat. That's wasteful.

The Solution

import { useEffectEvent } from 'react';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', onConnected);
connection.connect();
return () => connection.disconnect();
}, [roomId]); // theme no longer in dependencies
}

onConnected always sees the current theme value, but changing theme doesn't re-run the effect.

Rules for useEffectEvent

  1. Only call Effect Events from inside Effects
  2. Don't pass them to other components or hooks
  3. Define them right before the Effect that uses them
  4. Don't add them to dependency arrays (the ESLint plugin knows this)

When to Use It

Use useEffectEvent when your effect needs to read a value but shouldn't re-run when that value changes. Common cases:

  • Logging or analytics that read current state
  • Notifications that use current theme/locale
  • Callbacks that need fresh values but aren't the "reason" for the effect

In Makerkit, I migrated several functions to useEffectEvent, which cleaned up dependency arrays and reduced unnecessary effect re-runs.

Homework: Audit your useEffect hooks. If you have dependencies that don't logically trigger the effect, consider extracting that logic into a useEffectEvent.

See the official useEffectEvent documentation for more examples.

cacheSignal (React Server Components)

cacheSignal is a new API for React Server Components that returns an AbortSignal tied to the cache lifetime. When the cache expires or the render is aborted, the signal triggers, allowing you to cancel in-flight requests.

Why It Matters

Without cacheSignal, fetch requests initiated during server rendering continue even if React no longer needs the result. This wastes bandwidth and server resources.

Basic Usage

import { cache, cacheSignal } from 'react';
const fetchUserData = cache(async (userId: string) => {
const signal = cacheSignal();
const response = await fetch(`/api/users/${userId}`, { signal });
if (!response.ok) {
throw new Error('Failed to fetch user');
}
return response.json();
});

When React's cache lifetime ends (the render completes or fails), the AbortSignal fires, canceling any pending fetch.

When to Use It

Use cacheSignal when:

  • Making fetch requests in React Server Components
  • Performing any async operation that supports AbortSignal
  • You want to avoid wasted work when renders are interrupted

Integration with cache()

cacheSignal is designed to work with React's cache() function. The signal's lifetime matches the cache entry's lifetime:

import { cache, cacheSignal } from 'react';
const getProducts = cache(async (categoryId: string) => {
const signal = cacheSignal();
const [products, metadata] = await Promise.all([
fetch(`/api/products?category=${categoryId}`, { signal }).then(r => r.json()),
fetch(`/api/categories/${categoryId}`, { signal }).then(r => r.json()),
]);
return { products, metadata };
});

Both fetches cancel together if the cache is invalidated.

Production Gotcha: Handle AbortError

When a signal aborts, fetch throws an AbortError. If you don't handle it, you'll see unhandled promise rejections in your logs:

const fetchUserData = cache(async (userId: string) => {
const signal = cacheSignal();
try {
const response = await fetch(`/api/users/${userId}`, { signal });
return response.json();
} catch (error) {
// Don't rethrow AbortError - the render was intentionally cancelled
if (error instanceof Error && error.name === 'AbortError') {
return null;
}
throw error;
}
});

We hit this in Makerkit when renders were interrupted during navigation. The fix is straightforward: check for AbortError before rethrowing.

Partial Pre-rendering

Partial Pre-rendering (PPR) combines static and dynamic rendering in a single request. You pre-render a static shell to the CDN and stream dynamic content when the request arrives.

How It Works

  1. Build time: React pre-renders the static parts of your page (headers, navigation, product images)
  2. Request time: When a user requests the page, they immediately receive the static shell from the CDN
  3. Streaming: React resumes rendering the dynamic parts (personalized content, prices, stock levels) and streams them to the browser

Why It Matters

Traditional approaches force a choice:

  • Static Generation: Fast but can't personalize
  • Server Rendering: Personalized but slower TTFB

PPR gives you both: instant static content plus fresh dynamic data.

Use Cases

PPR works well for pages with clear static/dynamic splits:

  • E-commerce product pages: Static product info, dynamic pricing and stock
  • Dashboards: Static layout, dynamic data
  • Content sites: Static article, dynamic comments and recommendations

Code Example

import { Suspense } from 'react';
export default async function ProductPage({ params }) {
// Static: fetched at build time
const product = await getProduct(params.id);
return (
<main>
{/* Static shell - pre-rendered */}
<h1>{product.name}</h1>
<img src={product.image} alt={product.name} />
<p>{product.description}</p>
{/* Dynamic - rendered at request time */}
<Suspense fallback={<PriceSkeleton />}>
<DynamicPrice productId={params.id} />
</Suspense>
<Suspense fallback={<StockSkeleton />}>
<StockLevel productId={params.id} />
</Suspense>
</main>
);
}
async function DynamicPrice({ productId }) {
const pricing = await getPricing(productId); // Fresh on each request
return <span className="price">${pricing.current}</span>;
}

The static content serves instantly from the CDN. The dynamic components stream in as they resolve.

Framework Support

PPR is React's underlying streaming capability that frameworks build upon. How you enable it depends on your framework:

  • Next.js 15: PPR is available as an experimental ppr flag
  • Next.js 16: PPR evolved into Cache Components with explicit "use cache" directives, giving you more granular control

For Next.js 16 users, see our Next.js 16 upgrade guide for details on Cache Components, which replace the experimental PPR flag with a more explicit caching model.

SSR Improvements

React 19.2 includes two significant improvements to server-side rendering.

Suspense Boundary Batching

Previously, during streaming SSR, each Suspense boundary revealed its content immediately when ready. This could cause jarring visual updates.

In React 19.2, Suspense boundaries batch their reveals for a short window. If multiple boundaries resolve close together, they reveal simultaneously, creating smoother loading experiences.

This aligns server behavior with client-side rendering, where React already batches updates.

Web Streams for Node

React 19.2 adds Web Streams support for Node.js environments:

  • renderToReadableStream: Improved streaming API
  • prerender: Pre-render to a stream for PPR
  • resume: Resume a pre-rendered stream with dynamic content

These APIs enable PPR and improve streaming performance in Node-based deployments.

Chrome DevTools Performance Tracks

React 19.2 adds custom tracks to Chrome DevTools performance profiles, giving you visibility into React's internal scheduling.

Available Tracks

When you record a performance profile, you'll see:

  • Scheduler Track: Shows what React is working on and at what priority
  • Blocking: User interactions that need immediate response
  • Transition: Updates inside startTransition that can be deferred
  • Idle: Low-priority background work

How to Use Them

  1. Open Chrome DevTools
  2. Go to the Performance tab
  3. Record a profile while interacting with your app
  4. Look for the React-specific tracks in the flame chart

These tracks help you understand:

  • Why certain updates feel slow (blocked by other work?)
  • Whether transitions are being used effectively
  • If React is scheduling work at appropriate priorities

Practical Debugging

If your app feels sluggish during navigation:

  1. Record a performance profile
  2. Check if navigation updates show as "Blocking" instead of "Transition"
  3. Wrap navigation state updates in startTransition if appropriate

ESLint Plugin v7

The latest ESLint plugins for React introduce stricter rules about hooks usage. These rules prepare your code for the React Compiler and catch common bugs.

Makerkit uses strict ESLint rules, so we've already adapted to these changes. Here's what you need to know.

Setting State in useEffect

This common pattern is now flagged:

function MyComponent({ value }) {
const [state, setState] = useState(value);
// ❌ Not allowed
useEffect(() => {
setState(value);
}, [value]);
}

The fix is to update state during render:

function MyComponent({ value }) {
const [state, setState] = useState(value);
// ✅ Allowed
if (value !== state) {
setState(value);
}
return <div>{state}</div>;
}

This pattern is safe because React will re-render immediately with the new state, before committing to the DOM.

For more patterns, see You Might Not Need an Effect.

Scenario: useFetcher in React Router

useFetcher doesn't return a Promise, making it tempting to use useEffect for handling completion:

function MyComponent() {
const fetcher = useFetcher();
const [isSuccess, setIsSuccess] = useState(false);
// ❌ Setting state in effect
useEffect(() => {
if (fetcher.data?.success) {
setIsSuccess(true);
}
}, [fetcher.data]);
}

The fix is to derive state directly:

function MyComponent() {
const fetcher = useFetcher();
const isSuccess = fetcher.data?.success;
const isError = fetcher.data && !fetcher.data.success;
return (
<div>
{isSuccess && <div>Success</div>}
{isError && <div>Error</div>}
<fetcher.Form method="post">
<input type="text" name="name" />
<button type="submit">Submit</button>
</fetcher.Form>
</div>
);
}

For side effects like toasts, split into a form component with callbacks:

function MyForm({ onSuccess, onError }) {
const fetcher = useFetcher();
useEffect(() => {
if (fetcher.data) {
fetcher.data.success ? onSuccess?.() : onError?.();
}
}, [fetcher.data, onSuccess, onError]);
return (
<fetcher.Form method="post">
<input type="text" name="name" />
<button type="submit">Submit</button>
</fetcher.Form>
);
}
function MyContainer() {
const handleSuccess = useCallback(() => toast.success('Success'), []);
const handleError = useCallback(() => toast.error('Error'), []);
return <MyForm onSuccess={handleSuccess} onError={handleError} />;
}

Reading Refs in Render

Reading or writing ref.current during render is flagged:

function MyComponent() {
const ref = useRef(0);
// ❌ Not allowed
return <div>{ref.current}</div>;
}

If the value needs to appear on screen, use useState:

function MyComponent() {
const [count, setCount] = useState(0);
return <div>{count}</div>;
}

Refs are for values that:

  • Persist across renders without triggering re-renders
  • Don't need to appear in the UI
  • Are only read/written in effects or event handlers

Disabling Rules

If needed, disable per-line:

// eslint-disable-next-line react-hooks/set-state-in-effect

Or globally in your ESLint config:

// tooling/eslint/base.js
rules: {
'react-hooks/set-state-in-effect': 'off',
},

I recommend fixing the code rather than disabling rules. These patterns prepare your codebase for the React Compiler.

Conclusion

React 19.2 refines how React handles effects, state, and visibility. The new APIs (<Activity>, useEffectEvent, cacheSignal, Partial Pre-rendering) move React toward a more declarative model. The stricter ESLint rules enforce patterns that make code more predictable and Compiler-ready.

Most importantly: If you're running React 19.0.0 through 19.2.2, patch immediately. CVE-2025-55182 is a critical RCE vulnerability that's being actively exploited. Update to 19.2.3 and rotate your secrets.

For existing codebases, I recommend:

  1. Patch first: Update to React 19.2.3 and your framework's patched version
  2. Audit effects: Use useEffectEvent to clean up unnecessary dependencies
  3. Adopt Activity gradually: Start with tabs or modals where state preservation helps
  4. Enable strict ESLint rules: Prepare for the React Compiler

All Makerkit SaaS Starter Kits run on React 19.2.3 with all security patches applied:

We've migrated to the new patterns and can confirm they work well in production. If you're using Makerkit, make sure you're on the latest version to benefit from these security fixes.

Frequently Asked Questions

Is my React app vulnerable to React2Shell?
If you're using Next.js, check your Next.js version, not your React version. Next.js bundles its own React internally. Update to Next.js 14.2.35, 15.0.7, 15.1.11, 15.2.8, or 16.0.10 depending on your version line. For other frameworks, update react-server-dom-* packages to 19.2.3.
Can I use a WAF to protect against CVE-2025-55182?
No. WAF rules provide temporary mitigation at best. Attackers can modify payloads to bypass signature-based blocking. The only reliable fix is upgrading to patched versions and rotating secrets.
What's the difference between Activity and conditional rendering?
Conditional rendering (&&, ternary) unmounts components when hidden, destroying their state. Activity preserves state and defers updates while hidden, making it ideal for tabs, modals, and navigation where you want to maintain component state.
When should I use useEffectEvent vs regular functions?
Use useEffectEvent when your effect needs to read a value but shouldn't re-run when that value changes. Common cases: logging with current state, notifications using current theme, callbacks that need fresh values but aren't the trigger for the effect.
What is cacheSignal used for?
cacheSignal returns an AbortSignal tied to React's server-side cache lifetime. Use it to cancel fetch requests when renders are interrupted or cache expires, avoiding wasted network requests and improving server efficiency.
Do I need to update if I only use client-side React?
The critical RCE vulnerability (CVE-2025-55182) only affects apps using React Server Components. Pure client-side apps aren't vulnerable to this specific issue, but updating for the other features and bug fixes is still recommended.

Resources

React Documentation:

Related MakerKit Guides: