The React team has been busy. React 19.2 arrives with a collection of features that feel less like revolutionary changes and more like the framework finally catching up to what developers have been asking for all along. There's a certain elegance to this release; it's not about reinventing React, but about refining the developer experience and addressing real-world performance challenges that have plagued production applications for years.
React and React Native are transitioning to the React Foundation, an independent organization under the Linux Foundation with a governing board that includes Amazon, Meta, Microsoft, Vercel, and other major companies. While Meta remains committed with a five-year partnership including over $3 million in funding, this move shifts React from a Meta-led project to a vendor-neutral, community-driven ecosystem with independent governance.
The <Activity /> component
At first glance, <Activity /> looks like it belongs in the same family as spinners, skeletons, and other loading indicators. It does not. Its purpose is subtler, and more powerful. <Activity /> is about preserving UI in the background, not about signaling that work is in progress.
When an activity is hidden, React removes its children from view, cleans up their Effects, and keeps their state in memory. That means the interface can disappear without being discarded. When it becomes visible again, it returns in the same state it had before. The user is not starting over. The UI simply comes back.
This makes <Activity /> especially useful for parts of the interface that move in and out of view but should not lose their place: a sidebar full of filters, a tab panel, a detail drawer, or a settings section the user may revisit a moment later. Instead of unmounting that subtree and rebuilding it from scratch, you can hide it and restore it when needed:
import { Activity, useState } from 'react'
export function Dashboard() {
const [showSidebar, setShowSidebar] = useState(true)
return (
<div className="layout">
<button onClick={() => setShowSidebar((v) => !v)}>
Toggle sidebar
</button>
<Activity mode={showSidebar ? 'visible' : 'hidden'}>
<aside className="sidebar">
<h2>Filters</h2>
<label>
Search
<input type="text" />
</label>
</aside>
</Activity>
<main>
<h1>Dashboard</h1>
<p>Main content goes here.</p>
</main>
</div>
)
}
The important prop here is mode, which controls whether the wrapped content is visible or hidden. That is the central idea behind the component. There is no pending prop, and there is no type="refresh" mode. <Activity /> is not a loading primitive and does not decide how to represent progress.
That distinction matters because it is easy to confuse <Activity /> with useTransition. The two can appear in similar conversations, but they solve different problems. useTransition lets you mark updates as non-urgent and gives you a way to show pending feedback while React is working. <Activity />, by contrast, manages visibility while preserving state. One is about scheduling updates. The other is about keeping UI alive when it is temporarily out of sight.
Seen this way, <Activity /> is less like a spinner and more like a backstage area. The interface steps out of view, but it does not cease to exist. When it returns, it picks up where it left off. That small shift in mental model is the key to understanding why the component matters.
useEffectEvent: the hook we've been waiting for
Every React developer has written this code: an effect that depends on a function, which depends on props, which causes the effect to re-run way too often. The workarounds have become cargo-cult patterns: useCallback chains that make your component look like a game of dependency Jenga, the latest ref pattern with useRef, or that linter comment we all copy-paste to disable the exhaustive-deps warning.
useEffectEvent formalizes this pattern by separating what changes from what reacts to changes. It's a way to access the latest props and state inside an effect without making the effect re-run when those values change:
import { useEffect, useEffectEvent, useState } from 'react'
interface AnalyticsTrackerProps {
userId: string
pageName: string
}
export function AnalyticsTracker({ userId, pageName }: AnalyticsTrackerProps) {
const [sessionDuration, setSessionDuration] = useState(0)
const logEvent = useEffectEvent((eventName: string, data: object) => {
analytics.track(eventName, {
userId,
pageName,
timestamp: Date.now(),
...data,
})
})
useEffect(() => {
const startTime = Date.now()
logEvent('page_view', { startTime })
const interval = setInterval(() => {
const duration = Math.floor((Date.now() - startTime) / 1000)
setSessionDuration(duration)
logEvent('heartbeat', { duration })
}, 30000)
return () => {
const endTime = Date.now()
const totalDuration = Math.floor((endTime - startTime) / 1000)
logEvent('page_exit', { totalDuration })
clearInterval(interval)
}
}, [])
return (
<div className="fixed bottom-4 right-4 bg-slate-800 text-white px-3 py-2
rounded-lg text-sm opacity-50">
Session: {sessionDuration}s
</div>
)
}
Notice how clean the dependency array is now. The effect runs once, but logEvent always has access to the current userId and pageName. No more choosing between correctness and performance. No more useCallback chains that make you question your career choices.
Partial pre-rendering: the best of both worlds
The static versus dynamic debate has defined web development for a decade. Static site generators give you speed but sacrifice interactivity. Server-side rendering gives you dynamic content but at the cost of time-to-first-byte. Partial pre-rendering (PPR) says, why not both?
The idea is elegant: pre-render the static shell of your page at build time, but leave holes where dynamic content will be streamed in at request time. Your users see something instantly, and the personalized bits fill in as they arrive:
import { Suspense } from 'react'
function DashboardShell() {
return (
<div className="min-h-screen bg-slate-50">
<header className="bg-white border-b border-slate-200 px-8 py-4">
<h1 className="text-2xl font-bold text-slate-900">Dashboard</h1>
</header>
<main className="p-8">
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-3 gap-6 mb-8">
{/* Static promotional cards pre-rendered at build time */}
<div className="bg-gradient-to-br from-blue-500 to-blue-600
p-6 rounded-xl text-white">
<h3 className="text-lg font-semibold mb-2">New Feature</h3>
<p className="text-blue-100">
Check out our latest updates and improvements.
</p>
</div>
{/* Dynamic content will be streamed in */}
<Suspense fallback={<MetricCardSkeleton />}>
<UserMetrics />
</Suspense>
<Suspense fallback={<MetricCardSkeleton />}>
<RecentActivity />
</Suspense>
</div>
<Suspense fallback={<FeedSkeleton />}>
<PersonalizedFeed />
</Suspense>
</div>
</main>
</div>
)
}
async function UserMetrics() {
const metrics = await fetchUserMetrics()
return (
<div className="bg-white p-6 rounded-xl shadow-sm">
<h3 className="text-sm font-medium text-slate-600 mb-2">
Your Progress
</h3>
<p className="text-3xl font-bold text-slate-900">{metrics.score}</p>
<p className="text-sm text-green-600 mt-1">
+{metrics.improvement}% this week
</p>
</div>
)
}
async function RecentActivity() {
const activities = await fetchRecentActivity()
return (
<div className="bg-white p-6 rounded-xl shadow-sm">
<h3 className="text-sm font-medium text-slate-600 mb-3">
Recent Activity
</h3>
<div className="space-y-2">
{activities.slice(0, 3).map((activity) => (
<div key={activity.id} className="text-sm text-slate-700">
{activity.description}
</div>
))}
</div>
</div>
)
}
function MetricCardSkeleton() {
return (
<div className="bg-white p-6 rounded-xl shadow-sm animate-pulse">
<div className="h-4 bg-slate-200 rounded w-1/2 mb-3"></div>
<div className="h-8 bg-slate-200 rounded w-3/4"></div>
</div>
)
}
export default DashboardShell
With PPR enabled in your Next.js config, this page will be partially pre-rendered. The shell, navigation, layout, and static content are generated at build time and served instantly from the CDN. The dynamic components wrapped in Suspense boundaries are rendered on-demand when the user requests the page, with their content streaming in as it becomes available.
The user experience is immediate feedback followed by progressive enhancement. No more blank screens while waiting for database queries. No more choosing between performance and personalization.
Batching suspense boundaries for SSR
Server-side rendering has always had an awkward problem: what happens when you have multiple suspense boundaries on a page? Do you wait for all of them? Stream them one by one? Send them as they complete?
React 19.2 introduces intelligent batching. Related suspense boundaries, those that would appear on screen at the same time, are batched together and flushed as a group. This means fewer round-trips, less layout shifting, and a smoother perceived loading experience:
import { Suspense } from 'react'
async function ArticlePage({ params }: { params: { slug: string } }) {
return (
<article className="max-w-4xl mx-auto px-8 py-12">
{/* These boundaries are visually grouped, so React batches them */}
<Suspense fallback={<HeaderSkeleton />}>
<ArticleHeader slug={params.slug} />
</Suspense>
<div className="mt-8 prose prose-slate max-w-none">
<Suspense fallback={<ContentSkeleton />}>
<ArticleContent slug={params.slug} />
</Suspense>
</div>
<aside className="mt-12 border-t border-slate-200 pt-8">
{/* This is separate, so it might flush independently */}
<Suspense fallback={<CommentsSkeleton />}>
<CommentsSection slug={params.slug} />
</Suspense>
</aside>
</article>
)
}
React analyzes your component tree and makes intelligent decisions about what to batch together. The header and content arrive together because they're part of the primary reading experience. Comments might come later; they're useful but not critical to the initial render.
SSR: web streams support for Node
React 18 introduced two streaming APIs for server-side rendering, both of which remain the recommended choice in React 19, each optimized for different environments:
renderToPipeableStream: Use this in Node.js environments. It uses Node's native stream API, offers better performance, and supports built-in compression (gzip, brotli). This is still the recommended choice for traditional Node.js servers:
import { renderToPipeableStream } from 'react-dom/server'
import App from './App'
export function handler(req: Request, res: Response) {
const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/client.js'],
onShellReady() {
res.setHeader('Content-Type', 'text/html')
pipe(res)
},
onError(error) {
console.error('SSR Error:', error)
},
})
}
renderToReadableStream: Use this in edge runtimes (Cloudflare Workers, Deno, Vercel Edge Functions) that support Web Streams but not Node.js APIs:
import { renderToReadableStream } from 'react-dom/server'
import App from './App'
export async function handler(request: Request) {
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ['/client.js'],
onError(error) {
console.error('SSR Error:', error)
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/html'
}
})
}
The Web Streams API provides portability across edge runtimes, but don't switch from renderToPipeableStream if you're deploying to Node.js; you'd lose performance benefits and compression support for no gain. Choose the API that matches your deployment target.
eslint-plugin-react-hooks v6: smarter linting
The Rules of Hooks have always been somewhat magical, patterns that React requires but that JavaScript itself doesn't enforce. The ESLint plugin has done heroic work catching violations, but it's also been a source of frustration with false positives and overly strict warnings.
Version 6 understands React better. It knows about useEffectEvent and doesn't complain about its dependencies. It understands cacheSignal and doesn't force you to add it to dependency arrays. Most importantly, it's gotten better at understanding your intent:
import { useEffect, useEffectEvent, useState } from 'react'
interface TimerProps {
onTick: (count: number) => void
interval: number
}
export function Timer({ onTick, interval }: TimerProps) {
const [count, setCount] = useState(0)
const handleTick = useEffectEvent((currentCount: number) => {
onTick(currentCount)
})
useEffect(() => {
const timer = setInterval(() => {
setCount((c) => {
const newCount = c + 1
handleTick(newCount)
return newCount
})
}, interval)
return () => clearInterval(timer)
}, [interval])
return (
<div className="text-center p-8">
<div className="text-6xl font-bold text-slate-900 mb-4">{count}</div>
<div className="text-slate-600">seconds elapsed</div>
</div>
)
}
The linter is finally working with you instead of against you. It catches real problems while staying out of your way when you're using the new patterns correctly.
Update the default useId prefix
React 19 changes the default prefix for useId from colons (:r1:) to a CSS-safe format. This isn't about collision resistance but compatibility with the View Transitions API.
The View Transitions API uses CSS selectors to match elements during page transitions. The old useId format included colons, which are special characters in CSS (used for pseudo-classes like :hover). An ID like :r1: would require escaping in CSS selectors, and the View Transitions API couldn't match these elements without workarounds:
import { useId } from 'react'
export function FormField({ label, type = 'text' }: FormFieldProps) {
const id = useId()
return (
<div>
<label htmlFor={id}>
{label}
</label>
<input id={id} type={type} />
</div>
)
}
You still use useId exactly as before, since the API itself has not changed. The difference is that generated IDs now work seamlessly with CSS-based features such as View Transitions, document.querySelector, and CSS attribute selectors. If you have tests or snapshots that assert specific ID formats, they will need to be updated, but your application code remains unchanged.