Перейти к основному содержимому

Perceived Performance

Perceived Performance — насколько быстрым ощущается интерфейс вне зависимости от реального времени загрузки. Мгновенная обратная связь, skeleton-состояния и optimistic UI создают ощущение скорости даже при медленных запросах.


Принципы

MUST

  • Порог визуального отклика — 100ms — любое действие пользователя подтверждается мгновенно.
  • Индикация ожидания — для операций длиннее 100ms обязателен промежуточный статус.
  • Прогрессивная загрузка — контент отрисовывается в порядке: Layout → LCP → второстепенные модули.
  • Стабильность — контент не прыгает и не сдвигается в процессе загрузки (CLS < 0.1). Резервировать место под динамический контент через aspect-ratio, min-height.

SHOULD

  • Optimistic UI — для предсказуемых действий (лайки, избранное, переключатели) состояние обновляется до ответа сервера. Обязателен rollback при ошибке.
  • Skeleton вместо пустого состояния — пользователь видит структуру страницы до загрузки данных.

FORBIDDEN

  • Оставлять действие без визуального отклика — кнопка не должна выглядеть «мёртвой» после клика.
  • Блокировать весь интерфейс при ожидании ответа от сервера.

React Concurrent Features

MUST

Использовать Concurrent Features для тяжёлых обновлений:

useTransition — для тяжёлых обновлений (переключение вкладок, фильтрация). Не блокирует пользовательский ввод.

'use client';
import { useState, useTransition } from 'react';

export function Search() {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();

return (
<>
<input
value={query}
onChange={(e) =>
// обновление query — не срочное, не блокирует ввод
startTransition(() => setQuery(e.target.value))
}
/>
{isPending && <Spinner />}
</>
);
}

useDeferredValue — для отложенного обновления UI при быстром вводе.

'use client';
import { useState, useDeferredValue } from 'react';

export function FilteredList({ items }: { items: string[] }) {
const [filter, setFilter] = useState('');
const deferredFilter = useDeferredValue(filter); // обновляется с задержкой

const filtered = items.filter((i) => i.includes(deferredFilter));

return (
<>
<input value={filter} onChange={(e) => setFilter(e.target.value)} />
<List items={filtered} />
</>
);
}

Optimistic UI — useOptimistic

'use client';
import { useOptimistic } from 'react';

export function LikeButton({ initial }: { initial: number }) {
const [optimisticLikes, addOptimistic] = useOptimistic(initial);

async function onLike() {
addOptimistic((n) => n + 1); // мгновенно показываем +1
try {
await likeOnServer();
} catch {
// React автоматически откатит optimistic state при ошибке
}
}

return <button onClick={onLike}>Likes: {optimisticLikes}</button>;
}

Skeleton-состояния

Next.js — App Router (автоматически)

// app/products/loading.tsx
// Автоматически становится fallback для Suspense
export default function Loading() {
return <ProductsSkeleton />;
}

Next.js — Pages Router (вручную)

export default function ProductsPage() {
const { data, isLoading } = useProducts();

if (isLoading) return <ProductsSkeleton />;

return <ProductsList items={data} />;
}

Astro — постепенная инициализация

---
import Reviews from '../components/Reviews.jsx';
---

<!-- Инициализируется когда browser idle, не блокируя первый экран -->
<Reviews client:idle />

React Side Effects

FORBIDDEN

  • Использовать useEffect для синхронизации state (когда один useState зависит от другого).
// ❌ Плохо: useEffect для синхронизации state
const [items, setItems] = useState(data);
const [filtered, setFiltered] = useState(items);
useEffect(() => {
setFiltered(items.filter(...)); // лишний рендер
}, [items]);

// ✅ Хорошо: derived state в теле компонента
const [items, setItems] = useState(data);
const filtered = items.filter(...); // вычисляется при рендере

MUST

Обязательная очистка в useEffect:

useEffect(() => {
const timer = setTimeout(() => {}, 1000);
const interval = setInterval(() => {}, 500);
window.addEventListener('resize', handler);
const subscription = subscribe();

return () => {
clearTimeout(timer);
clearInterval(interval);
window.removeEventListener('resize', handler);
subscription.unsubscribe();
};
}, []);