React 19.3: browser(), Trusted Types and What to Adopt First
React 19.3 makes View Transitions and Fragment refs stable, but browser() and Trusted Types support are the changes I would put on a team's list this week.
Umut Korkmaz
Published: 4 min read
On this page6
React 19.3 landed on npm on September 9. The headline is that <ViewTransition> and Fragment refs, experimental last year, are now stable. The two changes I care more about as a code reviewer are smaller: a browser() API in react-dom that gives server-rendered apps an official way to say "this only makes sense in the browser", and Trusted Types support that removes a real blocker to running React under a strict Content Security Policy. Here is what changed, per the React 19.3 release post, and what I would do first.
browser() retires the isClient workaround
Many server-rendered codebases carry some version of the same hack: a mounted flag flipped in useEffect, or a typeof window !== 'undefined' check, for components that read localStorage or the device time zone. 19.3 makes this first-class. A Client Component calls use(browser()), and during server rendering React stops there and puts the nearest <Suspense> fallback into the HTML. In the browser, use(browser()) returns undefined and the component renders normally.
'use client';
import { Suspense, use } from 'react';
import { browser } from 'react-dom';
function RecentSearches() {
use(browser('Reads recent searches from localStorage.'));
const items = JSON.parse(localStorage.getItem('recent') ?? '[]');
return <ul>{items.map((q: string) => <li key={q}>{q}</li>)}</ul>;
}
export function RecentSearchesSlot() {
return (
<Suspense fallback={<p>Loading recent searches...</p>}>
<RecentSearches />
</Suspense>
);
}
Like other calls to use, it can sit inside a condition or after an early return. The release post shows a data hook that opts out of server rendering only when no initialData was passed in.
The browser reference page has caveats that belong in a review checklist:
- It must be inside a
<Suspense>boundary during server rendering. Without one, the server render fails. - It must be called from a Client Component, not a Server Component.
- Calling
browser()on its own does nothing. Pass it touse; do not throw it.
It also takes an optional reason, which becomes the cause of the error passed to the server renderer's onBrowserBailout callback; these bailouts skip onError. You can also pass browser() as an abort reason to hand pending boundaries to the client after a server timeout. That observability is what I like most. The old mounted-flag pattern was invisible: nothing told you how much of a page had quietly become client-only.
Trusted Types makes a strict CSP realistic
When a site sends Content-Security-Policy: require-trusted-types-for 'script', the browser requires values passed to injection sinks like innerHTML to be TrustedHTML, TrustedScript or TrustedScriptURL objects created through your own policies, not raw strings. Until now React coerced values to strings with '' + value before handing them to the DOM, which turned a TrustedHTML object back into a plain string the browser would reject. You could write a careful sanitization policy and React would undo it on the way out. In 19.3 React passes these objects through without coercion.
Two adjacent changelog fixes: a false-positive hydration mismatch on nonce attributes is gone, and React no longer sets innerHTML when it has not changed.
React does not write the policies for you; the sanitization is still your job. I am studying for CEH at the moment, and this is the kind of defense-in-depth control I would rather turn on than keep arguing about. The practical path is to ship the directive in report-only mode first, route every dangerouslySetInnerHTML through one policy, and let the reports show which third-party code still writes strings into the DOM.
View Transitions and Fragment refs are stable
<ViewTransition> animates elements as they enter, exit, update or move between places, using the browser's View Transition API. Only updates marked as Transitions animate: startTransition, a Suspense reveal, or useDeferredValue. addTransitionType lets the same state change animate differently depending on its cause, and React also exposes those types to CSS through :active-view-transition-type(). For Suspense, the advice is to animate only the fallback-to-content update, which update="auto" default="none" does. It is DOM-only for now, with React Native support in progress, so it does not help the Expo apps I ship yet.
Fragment refs put a ref on <Fragment> and give you a FragmentInstance with event listeners, focus/focusLast/blur, observeUsing for IntersectionObserver or ResizeObserver, getClientRects and scrollIntoView. That removes the wrapper <div> added only to hold a ref, and works around library components that do not pass their ref along.
Smaller changes for the upgrade ticket
- Transitions now render independently, so a slow one no longer holds up unrelated ones.
- Strict Mode now double-invokes Effects during hydration, matching client-rendered roots.
- Server Components can render a Context imported from a
'use client'module directly, without a Provider wrapper. Error.causeandAggregateError.errorsare now transported from the server to the client.
What I'm doing about it
- Grep for
typeof windowand mounted-flag effects, and move the genuinely browser-only ones touse(browser('...'))with a reason and a Suspense boundary. - Put Trusted Types in report-only on one app and read a week of violations before deciding on enforcement.
- Treat
<ViewTransition>as opt-in polish, not a migration. - Check which React version your framework ships first.
onBrowserBailoutand thebrowser()abort reason are options on the server renderer, which the framework usually owns.
The animation APIs will get the attention. The quieter changes are the ones that clean up real code.
Sources
Primary references used for this post.