Эта статья — перевод оригинальной статьи «React 19.3».
Также я веду телеграм канал «Frontend по‑флотски», где рассказываю про интересные вещи из мира разработки интерфейсов и AI.
Новые возможности React
View Transitions
Новый компонент <ViewTransition> позволяет анимировать элементы интерфейса при их появлении, исчезновении, перемещении или изменении размера с помощью браузерного View Transition API.
В прошлом году мы представили его как экспериментальный API, а в React 19.3 он получил стабильный статус и готов к использованию.
Чтобы добавить анимацию для отдельной части интерфейса, оберните её в <ViewTransition>:
import { ViewTransition } from 'react'; {isShowing && ( <ViewTransition> <Component /> </ViewTransition> )}
Теперь всякий раз, когда обновление, помеченное как Transition, изменяет стили дочернего компонента либо приводит к монтированию или размонтированию ViewTransition, React будет анимировать это изменение.
Какую именно анимацию запускать, React определяет по тому, как изменилось дерево:
enter —
<ViewTransition>добавляется.exit —
<ViewTransition>удаляется.update — у дочерних элементов
<ViewTransition>меняются стили или содержимое.share — именованный
<ViewTransition>удаляется в одном месте и добавляется в другом.
Важно: обновления, которые не помечены как Transition, не запускают анимацию. Они считаются срочными и должны сразу отражаться в интерфейсе.
View Transition будет анимироваться, если обновление состояния происходит внутри startTransition, при раскрытии <Suspense> или в результате обновления значения из useDeferredValue.
Ниже — простой пример анимации появления и исчезновения элемента:
Пример
import { ViewTransition, useState, startTransition } from 'react'; import { Video } from './Video'; import videos from './data'; export default function Component() { const [showItem, setShowItem] = useState(false); return ( <> <button onClick={() => { startTransition(() => { setShowItem((prev) => !prev); }); }}> {showItem ? '➖' : '➕'} </button> {showItem && ( <ViewTransition> <Video video={videos[0]} /> </ViewTransition> )} </> ); }
https://codesandbox.io/p/sandbox/sh9fks?file=%2Fsrc%2FApp.js
По умолчанию <ViewTransition> использует плавную cross-fade-анимацию. Для каждого типа перехода можно настроить собственную анимацию: передать View Transition Class и описать её в CSS либо использовать Web Animations API, чтобы запускать анимации императивно через событийные пропсы (onEnter, onExit, onShare, onUpdate).
Сейчас <ViewTransition> работает только с DOM. Мы также работаем над поддержкой React Native и других платформ.
Подробнее в документации <ViewTransition>.
addTransitionType
Иногда для одного и того же обновления состояния нужно использовать разные анимации. Например, если пользователь листает карусель вперёд к третьему слайду, слайды должны двигаться справа налево. А если он возвращается назад к тому же третьему слайду слева направо. При этом в обоих случаях currentSlide получает значение 3.
Настроить анимацию для конкретного View Transition можно с помощью вызова addTransitionType вместе с обновлением состояния. Это позволяет передать дополнительную информацию о причине, по которой произошёл конкретный переход:
function nextSlide() { startTransition(() => { addTransitionType('next'); setCurrentSlide(c => c + 1); }); } function previousSlide() { startTransition(() => { addTransitionType('previous'); setCurrentSlide(c => c - 1); }); }
После этого можно задать разные анимации в зависимости от типа перехода:
<ViewTransition enter={{ 'next': 'from-right', 'previous': 'from-left', }} exit={{ 'next': 'to-left', 'previous': 'to-right', }} > <Page /> </ViewTransition>
Вот пример:
Пример
import { ViewTransition, addTransitionType, useState, startTransition, Fragment } from 'react'; import { Video } from './Video'; import videos from './data'; import './animations.css'; export default function Component() { const [selected, setSelected] = useState(0) const video = videos[selected]; return ( <> <div className="button-container"> <button onClick={() => { startTransition(() => { addTransitionType('previous'); setSelected(c => c > 0 ? c - 1 : videos.length - 1 ) }); }}> ⬅️ </button> <button onClick={() => { startTransition(() => { addTransitionType('next'); setSelected(c => c + 1 < videos.length ? c + 1 : 0) }); }}> ➡️ </button> </div> <ViewTransition key={video.id} enter={{ 'next': 'from-right', 'previous': 'from-left' }} exit={{ 'next': 'to-left', 'previous': 'to-right' }} > <Video video={video} /> </ViewTransition> </> ); }
React также добавляет каждый Transition Type к элементу как браузерный view transition type, поэтому в CSS можно применять анимации выборочно с помощью :active-view-transition-type(...).
Подробнее — в документации addTransitionType.
Анимация fallback-состояний, изображений и шрифтов с Suspense
Одна из самых интересных особенностей View Transitions в React — их интеграция с Suspense.
Можно анимировать Suspense boundary в момент, когда она раскрывает дочерние элементы. Для этого достаточно обернуть её в <ViewTransition>:
<ViewTransition> <Suspense fallback={<Loading />}> <Component /> </Suspense> </ViewTransition>
Когда дочерние элементы загрузятся, React запустит анимацию типа update, плавно переходя от fallback-состояния к финальному содержимому.
Вот пример. Нажмите ➕, чтобы отрендерить LazyVideo, который при первом рендере приостанавливается через Suspense:
Пример
import { Suspense, useState, startTransition, use, ViewTransition } from 'react'; import { Video, VideoPlaceholder } from './Video'; import { fetchVideo } from './data'; export default function Component() { const [showItem, setShowItem] = useState(false); return ( <> <button onClick={() => { startTransition(() => { setShowItem((prev) => !prev); }); }} > {showItem ? '➖' : '➕'} </button> {showItem && ( <ViewTransition> <Suspense fallback={<VideoPlaceholder />}> <LazyVideo /> </Suspense> </ViewTransition> )} </> ); } function LazyVideo() { const video = use(fetchVideo()); return <Video video={video} />; }
Такой вариант работает, но есть нюанс: при последующих раскрытиях видео тоже будет анимироваться при появлении и исчезновении, хотя оно уже загружено. (Кроме того, при первом отображении fallback тоже может появляться с плавной анимацией.)
В целом анимации вместе с Suspense лучше использовать умеренно и избегать их для уже закэшированного интерфейса, который в обычной ситуации должен появляться мгновенно.
Вот несколько принципов, которые помогут сохранить хороший UX при анимации Suspense:
Fallback должен появляться сразу, без анимации
Переход от fallback к финальному содержимому должен происходить с анимацией
Дочерние элементы, которые не приостанавливаются через Suspense, должны появляться сразу, без анимации
Так приложение остаётся быстрым и отзывчивым, когда данные уже загружены, а анимация используется только там, где действительно помогает сделать переход от fallback к готовому содержимому более плавным.
<ViewTransition update="auto" default="none"> <Suspense fallback={<Fallback />}> <Component /> </Suspense> </ViewTransition>
Посмотрим, как теперь ведёт себя этот пример:
Пример
import { Suspense, useState, startTransition, use, ViewTransition } from 'react'; import { Video, VideoPlaceholder } from './Video'; import { fetchVideo } from './data'; export default function Component() { const [showItem, setShowItem] = useState(false); return ( <> <button onClick={() => { startTransition(() => { setShowItem((prev) => !prev); }); }} > {showItem ? '➖' : '➕'} </button> {showItem && ( <ViewTransition update="auto" default="none"> <Suspense fallback={<VideoPlaceholder />}> <LazyVideo /> </Suspense> </ViewTransition> )} </> ); } function LazyVideo() { const video = use(fetchVideo()); return <Video video={video} />; }
Обратите внимание: при нажатии на кнопку fallback появляется сразу, благодаря чему интерфейс мгновенно реагирует на действия пользователя. А после того как видео уже загрузилось, его последующее скрытие и отображение происходит без задержки.
В зависимости от того, какого эффекта вы хотите добиться, можно использовать и другие подходы. Подробнее в документации по анимации с Suspense.
Помимо анимации fallback-состояний, View Transitions позволяют подключать изображения и шрифты к Suspense на время их загрузки.
Это помогает избежать стандартного поведения браузера, при котором изображения или шрифты могут внезапно появляться в тот момент, когда завершилась их загрузка. Вместо этого можно выстраивать согласованные сценарии загрузки, учитывающие все ресурсы компонента.
Чтобы изображение или шрифт вызывали Suspense во время загрузки, оберните их в <ViewTransition>:
<ViewTransition> <Suspense fallback={<Fallback />}> <img src={imageSrc} /> <style href={fontSrc} precedence="default"> {`@font-face { font-family: 'Fancy'; src: url(${fontSrc}) format('truetype'); font-display: swap; }`} </style> </Suspense> </ViewTransition>
Вот пример компонента, который остаётся в состоянии Suspense до тех пор, пока не загрузятся его данные, изображение и шрифт:
Подробнее о том, как дожидаться загрузки изображений, шрифтов и таблиц стилей, читайте в документации Suspense.
Fragment Refs
Когда нужен более низкоуровневый контроль над DOM-узлами компонента, например, чтобы повесить обработчик события, отслеживать видимость или управлять фокусом, обычно можно использовать ref. Но в некоторых случаях это затруднительно:
Компонент рендерит группу соседних элементов без общего родителя
Компонент не передаёт свой проп
refдальше другому элементу
function Component() { // Как получить доступ к списку DOM-узлов, которые рендерит этот компонент? return ( {posts.map(post => ( <Heading key={post.id}> {post.title} </Heading> ))} ) }
Иногда можно просто добавить обёртку <div> ради ref, но это способно повлиять на стили или раскладку компонента. Кроме того, если компонент сам не предоставляет проп ref, его придётся дорабатывать, а это может быть невозможно, если он приходит из сторонней библиотеки.
Fragment Refs решают эти проблемы: они предоставляют ограниченный набор часто используемых DOM-методов, которые работают с любым React-компонентом независимо от того, что именно он рендерит.
В React 19.3 ref можно передать напрямую в <Fragment>. Такой ref предоставляет FragmentInstance, через который можно работать с DOM-дочерними элементами этого фрагмента:
function Component() { const fragmentRef = useRef(null); useEffect(() => { const fragmentInstance = fragmentRef.current; fragmentInstance.focus(); }, []); return ( <Fragment ref={fragmentRef}> {posts.map(post => ( <Heading key={post.id}> {post.title} </Heading> ))} </Fragment> ) }
FragmentInstance работает с DOM дочерних элементов как с единой группой, не меняя его структуру:
addEventListener,removeEventListenerиdispatchEventуправляют событиями для дочерних элементов первого уровня.focus,focusLastиblurпозволяют перемещать фокус по вложенным дочерним элементам в глубину.observeUsingиunobserveUsingподключаютIntersectionObserverилиResizeObserver.getClientRects,getRootNode,compareDocumentPositionиscrollIntoViewпозволяют измерять дочерние элементы первого уровня фрагмента и прокручивать к ним страницу.
Таким образом, Fragment Refs позволяют добавлять поведение к другим компонентам без необходимости менять их внутреннюю реализацию и без изменения уже создаваемой ими структуры DOM.
В этом примере показан компонент InView с пропом onChange, который вызывается каждый раз, когда его дочерние элементы входят в область видимости или покидают её:
Обратите внимание: InView может добавлять поведение к своим дочерним элементам, даже несмотря на отсутствие единого родительского DOM-элемента и на то, что Card не предоставляет проп ref.
Подробнее о работе с Fragment Refs — в документации <Fragment>.
Новые возможности React DOM
browser
Если ваше приложение использует серверный рендеринг, компоненты будут рендериться в двух разных окружениях:
На сервере: чтобы сформировать начальный HTML
На клиенте: чтобы дополнить этот HTML обработчиками событий
В большинстве случаев компоненты должны уметь генерировать HTML, который совпадает с результатом их первого клиентского рендера. Это обеспечивает корректную гидратацию и при этом позволяет показать пользователю как можно больше контента уже при первой загрузке страницы.
Но в редких случаях компонент не может отрендерить на сервере какой-либо осмысленный интерфейс. Например, он может зависеть от браузерного API вроде localStorage или использовать локальный часовой пояс браузера. В таких ситуациях может быть удобнее полностью исключить такой компонент из серверного рендеринга.
Раньше для этого можно было использовать состояние, которое обновляется внутри эффекта, или проверять наличие браузерных API вроде window:
function Component() { const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true) }, []) // ... } function Component() { const isBrowser = typeof window !== 'undefined'; // ... }
В React 19.3 для этого появился отдельный встроенный API.
Чтобы исключить компонент из серверного рендеринга, достаточно вызвать use(browser()):
import { use } from 'react'; import { browser } from 'react-dom'; function Component() { use(browser()); // ... }
На сервере этот вызов активирует Suspense, а на клиенте нет. Во время серверного рендеринга в HTML будет отображаться fallback ближайшей Suspense boundary. После гидратации компонента на клиенте use(browser()) уже не приостанавливает рендеринг, поэтому компонент продолжает рендериться как обычно.
Ниже пример компонента, который показывает локальный часовой пояс устройства. Нажмите Reload, чтобы сначала увидеть исходный HTML, а затем первый клиентский рендер React:
Пример
import { Suspense, use } from 'react'; import { browser } from 'react-dom'; function TimeZone() { use(browser()); const timeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone; return <p>{timeZone}</p> } export default function App() { return ( <> <p>Your current time zone is:</p> <Suspense fallback="Loading..."> <TimeZone /> </Suspense> </> ); }
Поскольку TimeZone приостанавливается на сервере, в исходный HTML попадает fallback из Suspense. После небольшой искусственной задержки React гидратирует страницу, и компонент уже может нормально отрендериться в браузере.
Таким образом, для компонентов, которые не могут сформировать осмысленный интерфейс во время серверного рендеринга, browser позволяет использовать Suspense для состояния загрузки. Благодаря этому такие компоненты могут работать в одном сценарии с другими компонентами, которые тоже приостанавливаются до тех пор, пока не будут готовы к рендерингу.
Как и другие вызовы use, use(browser()) можно использовать внутри условного выражения или после раннего return. Это позволяет писать компоненты и кастомные хуки, которые отключают серверный рендеринг только при выполнении определённого условия, например, в зависимости от значения пропа.
Ниже тот же пример, но теперь компонент TimeZone принимает необязательное значение по умолчанию, которое можно отрендерить уже в исходном HTML:
Пример
import { Suspense, use } from 'react'; import { browser } from 'react-dom'; function TimeZone({ defaultValue }) { if (defaultValue) { return <p>{defaultValue}</p>; } use(browser()); const localTimeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone; return <p>{localTimeZone}</p> } export default function App() { return ( <> <div> <p>The event's time zone is:</p> <TimeZone defaultValue='America/New_York' /> </div> <hr /> <div> <p>Your current time zone is:</p> <Suspense fallback="Loading..."> <TimeZone /> </Suspense> </div> </> ); }
Обратите внимание: TimeZone приостанавливается только во втором случае, когда значение по умолчанию не передано.
Ещё один полезный сценарий для такого подхода это отключение серверного рендеринга для data-fetching-хука вроде useQuery, если только начальные данные для этого запроса не были переданы заранее, например из Server Component или loader-функции фреймворка:
function useBrowserQuery(query, options) { if (options.initialData === undefined) { use(browser()); } return useQuery(query, options); } function ProductDetails({ productId, initialData }) { const product = useBrowserQuery(`/api/products/${productId}`, { initialData, }); return <h1>{product.name}</h1>; }
Теперь компонент ProductDetails можно включить в HTML, если во время серверного рендеринга ему передать initialData. Если начальных данных нет, компонент приостановится до тех пор, пока не начнёт рендериться в браузере. После этого useQuery сможет как обычно загрузить данные или прочитать их из кеша.
Подробнее о browser в документации.
Поддержка Trusted Types
React 19.3 теперь интегрируется с браузерным Trusted Types API, механизмом безопасности, который помогает предотвращать XSS-атаки на уровне DOM.
Если сайт включает Trusted Types через Content-Security-Policy: require-trusted-types-for 'script', браузер требует, чтобы значения, передаваемые в потенциально опасные точки внедрения вроде innerHTML, были не обычными строками, а типизированными объектами (TrustedHTML, TrustedScript, TrustedScriptURL), созданными с помощью ваших политик санитизации.
Раньше React всегда приводил такие значения к строке с помощью '' + value перед передачей в DOM API. Из-за этого объекты Trusted Types снова превращались в обычные строки, которые браузер затем отклонял.
Теперь React передаёт такие значения без преобразования, благодаря чему браузер может корректно их валидировать, а политики Trusted Types работают так, как задумано.
Новые возможности React Server Components
теперь можно рендерить напрямую в Server Components
Server Components не могут создавать Context, но могут рендерить его, импортируя из модуля с директивой 'use client'.
Раньше для этого клиентскому модулю приходилось экспортировать отдельный компонент-обёртку, который обычно называли Provider:
// user-context.js 'use client'; import { createContext } from 'react'; export const UserContext = createContext(null); export function UserProvider({ currentUser, children }) { return <UserContext value={currentUser}>{children}</UserContext>; }
// server-component.js import { UserProvider } from './user-context'; export async function Layout({ children }) { const currentUser = await getCurrentUser(); return ( <UserProvider currentUser={currentUser}> {children} </UserProvider> ) }
Обратите внимание: в этом примере Provider не делает ничего, кроме того, что напрямую передаёт проп из Server Component в Context.
В React 19.3 Server Components могут импортировать и рендерить Context напрямую из модуля с директивой 'use client' без дополнительного компонента-обёртки:
// user-context.js 'use client'; import { createContext } from 'react'; export const UserContext = createContext(null);
// server-component.js import { UserContext } from './user-context'; export async function Layout({ children }) { const currentUser = await getCurrentUser(); return ( <UserContext value={currentUser}> {children} </UserContext> ) }
Это особенно удобно для Context, которые существуют только для того, чтобы Server Components могли передавать данные остальной части клиентского дерева.
Changelog
Другие заметные изменения
react: Transitions теперь рендерятся независимо друг от друга, а не объединяются в один рендер. Благодаря этому медленный Transition больше не блокирует несвязанные с ним переходы #37290react-dom: В Strict Mode эффекты теперь вызываются дважды и во время гидратации так же, как и для корней, отрендеренных на клиенте #35961react: Добавлено предупреждение при некорректном использованииuseвнутри условного выражения #37104react: В сообщениях об ошибкахuseActionStateтермин «form state» заменён на «action state» #35790react-dom: Добавлена поддержка событийonFullscreenChangeиonFullscreenError#34621react-dom: Добавлена поддержка SVG-свойстваmaskType#35921react-dom: Добавлена поддержкаfetchPriorityдля модульных ресурсов #36835react-dom:onResetтеперь вызывается, когда React автоматически сбрасывает форму после Server Action #35176react-dom: В событияsubmitтеперь передаётсяsubmitter#35590react-dom: Атрибутcredentiallessуiframeтеперь распознаётся как булевый #36148react-dom: Обновления, вызванные событиямиresize, теперь объединяются в батч до следующего кадра #35117react-server: На клиент теперь передаютсяError.cause#35810 иAggregateError.errors#36156react-server: В Flight добавлена поддержка<Activity>#34697
Заметные исправления ошибок
react: Исправлена проблема, из-за которойuseDeferredValueмог застревать на старом значении #36134react: Исправлено распространение Context в fallback-компоненты Suspense #36160 и через приостановленные Suspense boundary #35839react: Исправлено зависание при обновлении дегидратированной Suspense boundary внутри скрытого дерева #37135react: Исправлена проблема, из-за которойuseSyncExternalStoreмог пропускать изменения store, произошедшие в то время, когда дерево<Activity>было скрыто #36947react: Исправлена работаuseEffectEvent: теперь он читает актуальные значения в компонентах сforwardRefиmemo#34831react: Исправлен сброс статуса формы при обновлении состояния компонента #34075react: Исправлено несколько ошибок Fast Refresh, связанных сlazy,memoи изменениями, которые меняют тип компонента #36965, #36964, #36963, #36950react: Исправлена ошибка, из-за которой<title>продолжал перемещаться в<head>после того, как содержащий его<Activity>переключался из режимаvisibleвhidden#34983react: Ошибки больше не выходят за пределы скрытого<Activity>#35074react: Содержимое порталов, отрендеренных внутри скрытого<Activity>, теперь также скрывается #35091react: Во внутренних сообщениях об ошибках больше не упоминается внутренний тип<Offscreen>#35763react-dom: Исправлена работа фокуса для делегированных и уже находящихся в фокусе элементов #36010react-dom: Исправлена утечка обработчиков вFragmentInstanceза счёт нормализации параметровcaptureв соответствии со спецификацией DOM #36047react-dom: Исправлен крэш<ViewTransition>в Mobile Safari #35337react-dom: Исправлен крэш<ViewTransition>при использовании сSuspenseList#35520react-dom: ПоведениеdefaultValueдляinput type="number"приведено в соответствие с другими типамиinput#36980react-dom: React больше не устанавливаетinnerHTML, если его значение не изменилось #36949react-dom: Исправлено ложное сообщение о hydration mismatch для атрибутовnonce#37030react-dom: Исправлено зависаниеreact-dom/serverв Deno #35235react-server: Исправлена потеря записейFormDataвdecodeReplyFromBusboy#36468react-server: Исправлены переполнение стека в длинных асинхронных цепочках #35612 иRangeError, возникавший из-за экспоненциального роста отладочной информации #37481
Полный список изменений доступен в Changelog.

