Скелетоны и спиннеры: как выбрать состояние загрузки
Спиннер, скелетон, оптимистичный UI или ничего — четыре подхода к тому, что показать пользователю, пока грузятся данные. Разбираем цену каждого и когда какой выбирать.

Пользователь нажал кнопку, запрос ушёл на бэкенд, ответ придёт через 400–1500 мс. Что показать в эти секунды — вопрос, на котором ломается ощущение скорости интерфейса. Крутящийся спиннер, серые прямоугольники-скелетоны, мгновенное обновление с откатом при ошибке или просто ничего. У каждого варианта своя цена в коде, в восприятии и в количестве багов. Разберём четыре подхода, покажем разницу в реализации на React и дадим критерий выбора, а не «зависит от задачи».
Почему «просто спиннер везде» — плохой ответ по умолчанию
Спиннер честно говорит «идёт загрузка», но у него два системных недостатка. Первый: он не сообщает, что грузится и сколько это займёт — на его месте может оказаться и одна строка, и таблица на 200 записей. Второй: спиннер, показанный на 150 мс и тут же скрытый, читается как мигание, и мозг воспринимает это как дефект, а не как загрузку.
Есть исследования восприятия времени (например, работы Nielsen Norman Group о порогах отклика), где граница «мгновенно» лежит около 100 мс, а «пользователь не теряет фокус» — около 1 секунды. Для запросов в этом диапазоне важнее не факт индикации, а то, насколько интерфейс кажется стабильным. Спиннер стабильности не даёт: он выкидывает контент и ставит на его место точку.
Скелетон продаёт не скорость, а предсказуемость: пользователь заранее видит, где появятся данные, и не переучивает взгляд после загрузки.
Четыре подхода и их цена
1. Ничего не показывать
Подходит для запросов быстрее ~200–300 мс, когда индикатор успеет только моргнуть. Цена: если сеть просела и ответ пришёл через секунду, пользователь увидит зависший интерфейс без обратной связи и может нажать кнопку повторно. Защита — блокировка повторного нажатия на время запроса, даже без визуального индикатора.
2. Спиннер / индикатор процесса
Универсальный вариант для действий без известной структуры результата: отправка формы, экспорт файла, «применить фильтры». Цена: layout shift. Контент пропадает, спиннер занимает другое место, при загрузке всё прыгает обратно. Ещё одна ловушка — показывать спиннер сразу: если ввести задержку 200–300 мс перед показом, короткие запросы обойдутся без мигания.
3. Скелетон
Серые плейсхолдеры формы будущего контента. Работает, когда структура ответа известна заранее: карточка товара, лента постов, строки таблицы. Цена: скелетон нужно поддерживать в такт с реальной разметкой. Изменили карточку — обновите скелетон, иначе при загрузке случится тот самый layout shift, от которого скелетон и должен спасать.
4. Оптимистичный UI
Интерфейс сразу показывает результат так, будто запрос уже успешен, а при ошибке откатывает. Идеален для лайков, добавления в корзину, переключения тумблеров. Цена самая высокая: нужна логика отката, обработка гонок (два быстрых клика) и честное сообщение об ошибке, если сервер отклонил операцию.
Как это выглядит в коде
Примеры для React 18 и TypeScript. Разница между подходами — в том, что мы рендерим на время ожидания, а не в обвязке запроса, поэтому саму загрузку абстрагируем до флага isLoading.
Скелетон вместо спиннера
// React 18. Скелетон повторяет структуру карточки,
// поэтому при появлении данных ничего не прыгает.
function ProductCard({ isLoading, product }: Props) {
if (isLoading) {
return (
<div className="card">
<div className="skeleton skeleton--image" />
<div className="skeleton skeleton--title" />
<div className="skeleton skeleton--price" />
</div>
);
}
return (
<div className="card">
<img src={product.image} alt={product.title} />
<h3>{product.title}</h3>
<span>{product.price} ₽</span>
</div>
);
}
Ключевой момент — размеры скелетона должны совпадать с реальными. Если картинка 200×200, скелетон-заглушка тоже 200×200, иначе смысл теряется.
Спиннер с задержкой перед показом
import { useState, useEffect } from "react";
// Показываем индикатор, только если ожидание превысило порог.
// Короткие ответы не успеют вызвать мигание.
function useDelayedFlag(active: boolean, delayMs = 250) {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!active) {
setVisible(false);
return;
}
const timer = setTimeout(() => setVisible(true), delayMs);
return () => clearTimeout(timer);
}, [active, delayMs]);
return visible;
}
// Использование:
// const showSpinner = useDelayedFlag(isLoading, 250);
Оптимистичное обновление с откатом
async function toggleLike(post: Post) {
const previous = post.liked;
// Сразу меняем состояние — интерфейс отвечает мгновенно.
setPost({ ...post, liked: !previous });
try {
await api.setLike(post.id, !previous);
} catch (error) {
// Сервер отклонил — возвращаем прежнее состояние
// и сообщаем об ошибке, а не молчим.
setPost({ ...post, liked: previous });
showToast("Не удалось сохранить, попробуйте ещё раз");
}
}
Если у вас React 19, для оптимистичных обновлений есть встроенный хук useOptimistic — он берёт на себя откат при завершении переходного состояния. Синтаксис и статус хука сверяйте с официальной документацией React, потому что API вводили постепенно и в разных версиях поведение отличалось.
Сравнение подходов
| Подход | Когда уместен | Layout shift | Сложность |
|---|---|---|---|
| Ничего | Ответ < 200–300 мс, структура не меняется | Нет | Минимальная |
| Спиннер | Действие без известной структуры результата | Высокий | Низкая |
| Скелетон | Известная структура: карточки, ленты, таблицы | Нет, если размеры совпадают | Средняя |
| Оптимистичный UI | Обратимые действия: лайк, корзина, тумблер | Нет | Высокая |
Когда что выбирать
Критерий простой — что вы знаете о результате на момент запроса.
- Знаете структуру и она не тривиальна (карточка, список из N элементов) — скелетон. Он держит layout и снимает ощущение прыжка.
- Структуру не знаете (экспорт, генерация отчёта, отправка формы) — спиннер с задержкой показа 200–300 мс.
- Действие обратимо и почти всегда успешно (лайк, чекбокс, сортировка) — оптимистичный UI.
- Запрос стабильно быстрый — ничего, но заблокируйте повторное нажатие.
Смешивать в одном экране можно и нужно: лента постов грузится скелетоном, а кнопка лайка внутри неё работает оптимистично.
Что ломается на практике
- Скелетон живёт своей жизнью. Разметку обновили, скелетон — нет. При загрузке контент всё равно прыгает. Лечится тем, что скелетон и реальный компонент делят общие размеры через один источник (CSS-переменные или общий контейнер).
- Спиннер без минимальной длительности. Ответ пришёл за 50 мс, спиннер мигнул — читается как глитч. Либо задержка перед показом, либо минимальное время отображения 300–400 мс, если уж показали.
- Оптимистичный UI без обработки гонок. Два быстрых клика по тумблеру — и состояние рассинхронизируется с сервером. Отменяйте предыдущий запрос или блокируйте элемент до ответа.
- Скелетон для ошибки. Запрос упал, а вы всё крутите скелетон бесконечно. Любое состояние загрузки обязано иметь ветку ошибки, иначе пользователь смотрит на серые прямоугольники, пока не перезагрузит страницу.
Что настроить дальше
Померьте Cumulative Layout Shift в Lighthouse или PageSpeed Insights до и после внедрения скелетонов — это объективный показатель, ушёл ли прыжок контента. Заведите единый компонент состояний (Loading, Error, Empty, Success), чтобы не забывать про ветку ошибки и пустого результата. И проверьте поведение на медленной сети через throttling в DevTools: подходы, которые выглядят хорошо на локалхосте, на 3G показывают всю свою цену.
Создатель презентаций: Структура и текст каждого слайда: заголовки-утверждения, одна мысль на слайд, заметки докладчику.
Спросить за 150 ₽Источники: React — useOptimistic reference, Nielsen Norman Group — Response Time Limits
Частые вопросы
А если у меня React 17, useOptimistic же не будет работать?
Скелетон мигает при быстрых ответах — что делать?
Оптимистичный UI сломался при двойном клике — состояние разъехалось с сервером. Как чинить?
Нужен ли скелетон, если данные грузятся за 100 мс?
Можно ли использовать один скелетон-компонент на весь проект?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.