React 19.3
- вторник, 22 сентября 2026 г. в 00:00:07
Эта статья — перевод оригинальной статьи «React 19.3».
Также я веду телеграм канал «Frontend по‑флотски», где рассказываю про интересные вещи из мира разработки интерфейсов и AI.
Новый компонент <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>.
Иногда для одного и того же обновления состояния нужно использовать разные анимации. Например, если пользователь листает карусель вперёд к третьему слайду, слайды должны двигаться справа налево. А если он возвращается назад к тому же третьему слайду слева направо. При этом в обоих случаях 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.
Одна из самых интересных особенностей 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.
Когда нужен более низкоуровневый контроль над 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>.
Если ваше приложение использует серверный рендеринг, компоненты будут рендериться в двух разных окружениях:
На сервере: чтобы сформировать начальный 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 в документации.
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 работают так, как задумано.
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 могли передавать данные остальной части клиентского дерева.
Другие заметные изменения
react: Transitions теперь рендерятся независимо друг от друга, а не объединяются в один рендер. Благодаря этому медленный Transition больше не блокирует несвязанные с ним переходы #37290
react-dom: В Strict Mode эффекты теперь вызываются дважды и во время гидратации так же, как и для корней, отрендеренных на клиенте #35961
react: Добавлено предупреждение при некорректном использовании use внутри условного выражения #37104
react: В сообщениях об ошибках useActionState термин «form state» заменён на «action state» #35790
react-dom: Добавлена поддержка событий onFullscreenChange и onFullscreenError #34621
react-dom: Добавлена поддержка SVG-свойства maskType #35921
react-dom: Добавлена поддержка fetchPriority для модульных ресурсов #36835
react-dom: onReset теперь вызывается, когда React автоматически сбрасывает форму после Server Action #35176
react-dom: В события submit теперь передаётся submitter #35590
react-dom: Атрибут credentialless у iframe теперь распознаётся как булевый #36148
react-dom: Обновления, вызванные событиями resize, теперь объединяются в батч до следующего кадра #35117
react-server: На клиент теперь передаются Error.cause #35810 и AggregateError.errors #36156
react-server: В Flight добавлена поддержка <Activity> #34697
Заметные исправления ошибок
react: Исправлена проблема, из-за которой useDeferredValue мог застревать на старом значении #36134
react: Исправлено распространение Context в fallback-компоненты Suspense #36160 и через приостановленные Suspense boundary #35839
react: Исправлено зависание при обновлении дегидратированной Suspense boundary внутри скрытого дерева #37135
react: Исправлена проблема, из-за которой useSyncExternalStore мог пропускать изменения store, произошедшие в то время, когда дерево <Activity> было скрыто #36947
react: Исправлена работа useEffectEvent: теперь он читает актуальные значения в компонентах с forwardRef и memo #34831
react: Исправлен сброс статуса формы при обновлении состояния компонента #34075
react: Исправлено несколько ошибок Fast Refresh, связанных с lazy, memo и изменениями, которые меняют тип компонента #36965, #36964, #36963, #36950
react: Исправлена ошибка, из-за которой <title> продолжал перемещаться в <head> после того, как содержащий его <Activity> переключался из режима visible в hidden #34983
react: Ошибки больше не выходят за пределы скрытого <Activity> #35074
react: Содержимое порталов, отрендеренных внутри скрытого <Activity>, теперь также скрывается #35091
react: Во внутренних сообщениях об ошибках больше не упоминается внутренний тип <Offscreen> #35763
react-dom: Исправлена работа фокуса для делегированных и уже находящихся в фокусе элементов #36010
react-dom: Исправлена утечка обработчиков в FragmentInstance за счёт нормализации параметров capture в соответствии со спецификацией DOM #36047
react-dom: Исправлен крэш <ViewTransition> в Mobile Safari #35337
react-dom: Исправлен крэш <ViewTransition> при использовании с SuspenseList #35520
react-dom: Поведение defaultValue для input type="number" приведено в соответствие с другими типами input #36980
react-dom: React больше не устанавливает innerHTML, если его значение не изменилось #36949
react-dom: Исправлено ложное сообщение о hydration mismatch для атрибутов nonce #37030
react-dom: Исправлено зависание react-dom/server в Deno #35235
react-server: Исправлена потеря записей FormData в decodeReplyFromBusboy #36468
react-server: Исправлены переполнение стека в длинных асинхронных цепочках #35612 и RangeError, возникавший из-за экспоненциального роста отладочной информации #37481
Полный список изменений доступен в Changelog.