← Усі статті

React 19.3: мініатюра фото виростає в картку і не втрачає стан

У React 19.3 мініатюра в сітці виростає у великий кадр і не скидає обраний знімок у списку.

React 19.3: мініатюра фото виростає в картку і не втрачає стан
Зміст

Коротко

Сітка знімків і великий кадр — як дві сторінки одного альбому на столі. Палець торкається маленької картки, вона має вирости у великий відбиток, а книжка не закривається.

У React 19.3 для цього є стабільний компонент <ViewTransition>. Він чекає, поки дерево справді перемалюється, і лише тоді просить браузер зняти кадр «після».

Однакове ім'я в мініатюри і у великого зображення каже, який старий елемент замінює новий, навіть якщо за той самий клік змінилися фільтр і обраний ідентифікатор.

Що сталося

Розбір звірено з React 19.3.0. У журналі змін на GitHub від 9 вересня 2026 року <ViewTransition> і addTransitionType позначені як стабільні, не як пробна збірка. Текст розрахований на функційні компоненти й хуки цієї доби React.

Браузерний API переходів вигляду вміє вузьку схему. Виклик document.startViewTransition() обгортає синхронну правку DOM. Старий і новий елементи ділять view-transition-name, і браузер плавно веде один прямокутник в інший.

Клік по мініатюрі в React майже ніколи не є такою правкою. Виклик на кшталт setSelectedId не чіпає DOM: він лише просить перемалювання. Поки відфільтрований список, рядок пошуку й обраний знімок уже узгоджені, браузер часто встиг зняти «після» зі старого дерева. Порівнюються два однакові кадри. Рух смикається, чіпляється не за той елемент або не починається, і інструменти розробника цього не пояснюють.

Обхід через flushSync змушує відмалювання статися одразу, всередині переданої функції. Ціна — синхронний блокувальний прохід, саме те, від чого йде одночасне відмалювання React. І навіть тоді браузер не знає, яка мініатюра у відфільтрованому списку відповідає великому кадру: він розчиняє весь контейнер.

<ViewTransition> закриває щілину. Компонент дивиться на власну фіксацію React у DOM і на очікування Suspense, а не припускає одну синхронну зміну.

У статті окремо застережено: «перехід» тут не синонім анімації. Це та сама позначка оновлення, що в startTransition і useTransition. Оновлення можна перервати, воно не найтерміновіше. Обгортка реагує лише на таку позначку. Звичайний setState без неї малює миттєво, наче компонента немає. Підійдуть прямий startTransition, useDeferredValue або маршрутизатор, який сам позначає зміну сторінки.

Коли оновлення позначене, кожен обгорнутий вузол потрапляє рівно в одну з чотирьох форм. Це не чотири окремі бібліотеки руху, а чотири відповіді на питання «що сталося з цим шматком дерева».

Форма Коли спрацьовує
enter вузол уперше вставлено в дерево за час цього переходу
exit вузол уперше прибрано
update вузол лишився, але змінилися вміст, розмір або положення, часто через сусіда
share іменований вузол зник в одному піддереві й з'явився з тим самим name в іншому

Останній рядок — якраз альбом: маленька картка і великий відбиток для узгодження дерева різні вузли, а для ока це один знімок.

Чому це важливо

Клік по сітці не замінює документ цілком. Разом з обраним знімком можуть закритися пошук, змінитися сторінка списку, а дані ще не приїхати. Браузерний виклик чекає, що код усередині функції сам і одразу змінює DOM. React описує намір. Фіксація може бути відкладена, зібрана в один пакунок або втримана, поки Suspense не отримає дані.

Поки ця різниця не закрита, спільний елемент живе лише на статичній сторінці. Список стає динамічним — і мініатюра з великим кадром для браузера вже чужі вузли. Плавне зростання розсипається.

Ім'я з обох боків робить тотожність явною. За звичайного узгодження React відрізняє повторне відмалювання вузла від демонтування, але два різні піддерева сам не склеїть. Форма share відповідає на питання «це той самий кадр?» іменем, коли власної тотожності вузла не вистачає.

Мітка addTransitionType не вирішує, чи буде рух. Форму й далі обирають enter, exit, update і пара зі спільним name. Мітка лише називає причину, щоб CSS відрізнив крок уперед від кроку назад без зайвої властивості, протягнутої по дереву.

З тієї ж версії 19.3 кілька таких оновлень більше не злипаються в один прохід. Друге, побічне, не мусить чекати, поки перше повільне ще рахується.

Компілятор React стабільної версії 1.0 до цього не стосується. Він запам'ятовує значення, пораховані під час відмалювання, і не змінює ні цих переходів, ні Suspense, ні життєвого циклу браузерного переходу вигляду.

На практиці

Компонент доречний там, де оновлення і так іде через відкладуваний перехід: зміна маршруту, фільтр, вкладка, оптимістичне оновлення. Має сенс, якщо результат має відчуватися безперервним. Перестановка списку, розкриття картки, мініатюра, яка стає великим кадром.

Іменувати варто лише те, що око має впізнати. Іменована обгортка на кожному вузлі — облік рухів, яких ніхто не помітить.

Не загортайте оновлення в startTransition лише заради анімації. Якщо зміна термінова і її не можна переривати, чесніше звичайне правило переходу CSS на самому елементі.

Заміна цілком завантажених документів і далі є завданням правила @view-transition { navigation: auto; }, не цього компонента.

Мініатюра і великий кадр зі статті зв'язані так:

function Thumbnail({ photo, onSelect }) {
  return (
    <ViewTransition name={`photo-${photo.id}`}>
      <img
        src={photo.thumbUrl}
        onClick={() => startTransition(() => onSelect(photo.id))}
      />
    </ViewTransition>
  );
}

function DetailView({ photo }) {
  return (
    <ViewTransition name={`photo-${photo.id}`}>
      <img src={photo.fullUrl} className="detail-image" />
    </ViewTransition>
  );
}

Якщо за один перехід вузол із цим іменем зник, а інший із тим самим іменем з'явився, браузер веде положення і розмір від маленької картинки до великої. Фільтр і сортування в тому самому кліку не заважають: тотожність задана іменем, а не місцем у масиві. Так альбом лишається відкритим. Маленький відбиток виростає у великий, бо сторінку вже перекладено.

Поява і зникнення потребують найменше налаштування. Властивості enter і exit приймають auto (розчинення, яке браузер ставить сам), none або ім'я класу. Стилі пишуть на псевдоелементах ::view-transition-new і ::view-transition-old. Самі ви не викликаєте document.startViewTransition: React вирішує, коли кликати браузер.

Вузол, який лишається змонтованим, але змінює висоту, оформлюють через update, не через появу і зникнення. Сусід, якого зсунули чужою зміною розміру, теж отримує update: зсув положення входить у це визначення.

Рядок, переданий в addTransitionType, іде в тип браузерного переходу. Селектор :active-view-transition-type() збігається лише з коренем документа, тому правило псевдоелемента вкладають усередину:

:root:active-view-transition-type(nav-back) {
  &::view-transition-old(.card) {
    animation: slide-out-right 200ms;
  }
}

Краї, про які просить пам'ятати вихідний розбір:

  1. Обгортка навколо зміни поза переходом мовчить. Найчастіше винен звичайний onClick={() => setState(x)}, а не забута властивість.
  2. Ім'я в кожному піддереві в мить обміну має бути унікальним, як view-transition-name у браузера. Два одночасно змонтовані вузли з одним іменем — помилка: у розробці React пише в журнал обидва. Беріть стійкий ідентифікатор знімка, а не порядковий номер у масиві.
  3. Вміст, який тримає Suspense, зазвичай відкладає знімок «після», а не ламає сам рух. React чекає готовності, щоб не анімувати в індикатор завантаження. Якщо між зникненням і появою пари зі спільним іменем все ж показався запасний інтерфейс, спільного переходу для цієї пари не буде.
  4. Без підтримки API в браузері компонент має миттєво оновити екран, а не кинути помилку. У статті підтримку віднесено до Chrome і Safari, в інших місцях лишаються прогалини. Перевірте свої цільові браузери і не давайте відсутньому API зламати саме оновлення.
  5. Це не бібліотека руху загального призначення: немає фізики пружин, почергового запуску і часової шкали. Ви стилізуєте два псевдоелементи CSS. Усе, що ширше за появу, зникнення, зміну розміру і спільний елемент, і далі віддайте окремій бібліотеці.

Підсумок

Збій у сітці був не в ідеї плавного кадру. Синхронний браузерний виклик просили описати відкладене оновлення React.

startTransition повідомляє, що зміну взагалі варто показувати в русі. Однакове ім'я повідомляє, яку мініатюру замінює великий кадр, навіть якщо фільтр перетасував список.

Альбом після цього не закривається: маленький знімок виростає у великий уже на новій сторінці, без flushSync і без ручного вибору миті знімка.

Коментарі

Завантаження коментарів…