javascript

React 19.3

  • вторник, 22 сентября 2026 г. в 00:00:07
https://habr.com/ru/articles/1084612/

Эта статья — перевод оригинальной статьи «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>
    </>
  );
}

https://codesandbox.io/p/sandbox/w2sqll

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} />;
}

https://codesandbox.io/p/sandbox/35j6xn

Такой вариант работает, но есть нюанс: при последующих раскрытиях видео тоже будет анимироваться при появлении и исчезновении, хотя оно уже загружено. (Кроме того, при первом отображении 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} />;
}

https://codesandbox.io/p/sandbox/jpm23l

Обратите внимание: при нажатии на кнопку 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>
    </>
  );
}

https://codesandbox.io/p/sandbox/6ghn7s

Поскольку 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>
    </>
  );
}

https://codesandbox.io/p/sandbox/m5xxlq

Обратите внимание: 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 больше не блокирует несвязанные с ним переходы #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.