Восприятие производительности веб-приложения пользователем (Perceived Performance) определяется не только абсолютной скоростью ответа сервера по метрикам Core Web Vitals (TTFB, LCP), но и мгновенностью локального отклика интерфейса (Latency to Feedback). В классической архитектуре клиент-серверного взаимодействия любая мутация или переход по ссылке заставляют пользователя ждать сетевой круг (Network Round-Trip Time, RTT). Задержка даже в 200–300 мс без немедленной визуальной индикации воспринимается как зависание интерфейса («UI freeze»).
В экосистеме Next.js 16 и React 19 эта проблема решается тремя взаимодополняющими примитивами:
useLinkStatus() (Next.js 16): предоставляет реактивный статус перехода для конкретных навигационных ссылок, позволяя отображать локальные индикаторы прогресса (инлайн-спиннеры, скелетоны) непосредственно на кнопке или ссылке, инициировавшей переход, до загрузки серверного компонента.useOptimistic() (React 19): позволяет синхронно обновить пользовательский интерфейс предполагаемым результатом асинхронного Server Action еще до отправки или завершения HTTP-запроса, обеспечивая нулевую задержку отклика с автоматическим откатом (rollback) при возникновении серверной ошибки.<ViewTransition> (React 19): переводит морфинг DOM-элементов при смене страниц и обновлении списков на аппаратный графический конвейер браузера с использованием именованных CSS-слоев view-transition-name.Вместо последовательного ожидания сети взаимодействие разделяется на оптимистичный цикл отрисовки и фоновую синхронизацию состояния:
+-----------------------------------------------------------------------------------+
| ОПТИМИСТИЧНАЯ НАВИГАЦИЯ И МУТАЦИИ В REACT 19 / NEXT.JS 16 |
+-----------------------------------------------------------------------------------+
| ДЕЙСТВИЕ ПОЛЬЗОВАТЕЛЯ ОПТИМИСТИЧНЫЙ СЛОЙ (0 мс) СЕРВЕРНЫЙ СЛОЙ (~300 мс)|
| |
| 1. Клик по ссылке <Link> ----> [ useLinkStatus().pending ] |
| | Индикатор загрузки на | |
| | конкретной карточке | |
| +------------------------> | Запрос RSC Flight |
| | Стриминг HTML-чанков |
| |
| 2. Добавление лайка/заметки -> [ useOptimistic() ] |
| (Клик по кнопке) | Локальный стейт мутирует | |
| | мгновенно (+1 лайк) | |
| | | |
| | <ViewTransition> | |
| | Аппаратный кроссфейд | |
| +------------------------> | Выполнение |
| | Server Action |
| | / \ |
| [ Авто-откат (Rollback) ] <- Успех Сбой |
| (при сбое восстанавливает (sync) (revalidate) |
| прежнее состояние) |
+-----------------------------------------------------------------------------------+Пользователь отправляет форму или кликает интерактивный элемент. Обработчик оборачивается в startTransition() для маркировки мутации как неблокирующей.
Функция setOptimisticState мгновенно обновляет виртуальный DOM фиктивной записью с временным идентификатором temp-id.
Браузер захватывает снимок старого состояния (::view-transition-old) и морфирует его в снимок нового оптимистичного состояния (::view-transition-new).
Server Action возвращает персистентный результат. Если операция успешна, React заменяет оптимистичный стейт на реальный; при ошибке происходит бесшовный откат.
useLinkStatus) при медленном мобильном 3G/LTE без мигания белого экрана.Стандартный loading.tsx или глобальный индикатор страницы отображают спиннер для всего маршрута. В современных интерфейсах требуется локальная индикация: если пользователь кликнул на конкретный элемент в боковом меню или карточку в каталоге, индикатор должен появиться именно на этом элементе.
В Next.js 16 хук useLinkStatus() связывается с навигационным контекстом App Router:
'use client';
import Link from 'next/link';
import { useLinkStatus } from 'next/navigation';
import { Loader2, ArrowRight } from 'lucide-react';
interface CatalogCardLinkProps {
href: string;
slug: string;
title: string;
price: number;
}
export function CatalogCardLink({ href, title, price }: CatalogCardLinkProps) {
// useLinkStatus отслеживает, инициирован ли переход по данному URL
const { pending } = useLinkStatus({ href });
return (
<Link
href={href}
className={`group relative block p-5 rounded-2xl border transition-all duration-300 ${
pending
? 'border-accent-cyan/60 bg-accent-cyan/5 shadow-md scale-[0.99]'
: 'border-border bg-bg-surface hover:border-accent-cyan/40 hover:shadow-xl'
}`}
>
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-bold text-text-primary group-hover:text-accent-cyan transition-colors">
{title}
</h3>
<p className="text-sm text-text-muted mt-1">{price.toLocaleString('ru-RU')} ₽</p>
</div>
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-bg-elevated border border-border group-hover:border-accent-cyan/40">
{pending ? (
<Loader2 className="w-5 h-5 text-accent-cyan animate-spin" />
) : (
<ArrowRight className="w-5 h-5 text-text-muted group-hover:text-accent-cyan group-hover:translate-x-0.5 transition-all" />
)}
</div>
</div>
{pending && (
<span className="absolute bottom-2 left-5 text-[11px] font-mono text-accent-cyan/80 animate-pulse">
Загрузка страницы товара...
</span>
)}
</Link>
);
}Рассмотрим полноценный сценарий управления списком задач или фидбэком в реальном времени. Мы используем React 19 useOptimistic совместно с useActionState или useTransition:
'use client';
import { useOptimistic, useTransition, useRef } from 'react';
import { addTaskServerAction } from '@/app/actions/tasks';
import { CheckCircle2, Clock } from 'lucide-react';
export interface TaskItem {
id: string;
title: string;
completed: boolean;
isSending?: boolean;
}
interface Props {
initialTasks: TaskItem[];
}
export function OptimisticTaskFeed({ initialTasks }: Props) {
const [isPending, startTransition] = useTransition();
const formRef = useRef<HTMLFormElement>(null);
// useOptimistic принимает базовое состояние и функцию редьюсер
const [optimisticTasks, setOptimisticTask] = useOptimistic(
initialTasks,
(currentTasks: TaskItem[], newTaskTitle: string): TaskItem[] => [
...currentTasks,
{
id: `temp-${Date.now()}`,
title: newTaskTitle,
completed: false,
isSending: true, // Маркер оптимистичного элемента
},
]
);
const handleFormSubmit = async (formData: FormData) => {
const title = formData.get('title') as string;
if (!title || !title.trim()) return;
formRef.current?.reset();
startTransition(async () => {
// 1. Мгновенно применяем изменение в интерфейсе
setOptimisticTask(title);
// 2. Отправляем сетевой запрос на бэкенд
try {
await addTaskServerAction(title);
} catch (error) {
console.error('Ошибка добавления задачи на сервере:', error);
// При ошибке React автоматически откатит optimisticTasks к исходному initialTasks
}
});
};
return (
<div className="max-w-xl mx-auto space-y-6">
<form ref={formRef} action={handleFormSubmit} className="flex gap-3">
<input
name="title"
placeholder="Новая задача..."
className="flex-1 px-4 py-2.5 rounded-xl border border-border bg-bg-surface text-text-primary focus:outline-none focus:border-accent-cyan"
disabled={isPending}
/>
<button
type="submit"
className="px-5 py-2.5 rounded-xl bg-accent-cyan text-bg-base font-semibold hover:opacity-90 disabled:opacity-50 transition-opacity"
>
Добавить
</button>
</form>
<div className="space-y-2">
{optimisticTasks.map((task) => (
<div
key={task.id}
className={`p-4 rounded-xl border flex items-center justify-between transition-all ${
task.isSending
? 'border-dashed border-accent-violet/50 bg-accent-violet/5 opacity-80'
: 'border-border bg-bg-surface'
}`}
>
<span className="text-text-primary font-medium">{task.title}</span>
<div className="flex items-center gap-1.5 text-xs font-mono">
{task.isSending ? (
<>
<Clock className="w-4 h-4 text-accent-violet animate-spin" />
<span className="text-accent-violet">Синхронизация...</span>
</>
) : (
<>
<CheckCircle2 className="w-4 h-4 text-success" />
<span className="text-text-muted">Сохранено</span>
</>
)}
</div>
</div>
))}
</div>
</div>
);
}В React 19 появился компонент <ViewTransition> для автоматического связывания изменений дерева компонентов с аппаратным View Transitions API браузера. Браузер захватывает старое визуальное состояние (::view-transition-old) и интерполирует его в новое (::view-transition-new) с ускорением GPU без использования тяжелых внешних JavaScript-библиотек анимации.
import { ViewTransition } from 'react';
import Image from 'next/image';
interface ProductCardProps {
id: string;
title: string;
imageUrl: string;
}
export function ProductCard({ id, title, imageUrl }: ProductCardProps) {
return (
<article className="p-4 rounded-2xl border border-border bg-bg-surface overflow-hidden">
{/* Декларативная граница View Transition */}
<ViewTransition name={`product-hero-${id}`}>
<div className="relative aspect-video rounded-xl overflow-hidden bg-bg-elevated">
<Image
src={imageUrl}
alt={title}
fill
className="object-cover transition-transform duration-500 hover:scale-105"
sizes="(max-width: 768px) 100vw, 33vw"
/>
</div>
</ViewTransition>
<ViewTransition name={`product-title-${id}`}>
<h2 className="mt-4 text-xl font-bold text-text-primary">{title}</h2>
</ViewTransition>
</article>
);
}/* Настройка длительности и тайминга аппаратного морфинга */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 250ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Отключение анимации для пользователей с чувствительностью к движению */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: 0.01ms !important;
}
}React 19 фиксирует изменение Virtual DOM внутри границы ViewTransition и обращается к Document View Transitions API.
Графический движок браузера создает текстуру исходного узла с привязкой к именованному селектору view-transition-name.
Выполняется обновление React DOM. Браузер захватывает снимок целевого элемента.
GPU выполняет сглаженную интерполяцию геометрии, масштаба и прозрачности с частотой 120 кадров/сек без нагрузки на главный поток JS.
Если внутри метода .map() задать статическое имя style={{ viewTransitionName: 'card-image' }}, то при рендере нескольких карточек на странице браузер обнаружит дубликаты и проигнорирует анимацию перехода. Всегда формируйте динамические уникальные имена с привязкой к первичному ключу сущности: viewTransitionName: 'card-image-' + item.id.
Хук useOptimistic жестко привязан к жизненному циклу React Transition. Если попытаться вызвать диспетчер обновления setOptimisticTask(...) внутри обычного синхронного обработчика событий без обертки в startTransition(...) или Server Action, React выбросит предупреждение в консоль, а оптимистичное состояние не сможет автоматически синхронизироваться и сброситься при ошибке.
Форсирование долгих View Transitions для пользователей с вестибулярными расстройствами ухудшает доступность (a11y). Всегда оборачивайте кастомные селекторы ::view-transition-group в медиа-запрос @media (prefers-reduced-motion: reduce) со сбросом длительности анимации в 0.01ms.
Если в оптимистичный объект передать фиктивный идентификатор типа temp-123, а дочерний компонент использует его как ключ базы данных для вложенных сетевых запросов без проверки флага isSending, возникнут 404 ошибки в параллельных вызовах. Всегда маркируйте оптимистичные элементы отдельным булевым флагом (isOptimistic: true).