Перейти к содержанию

Загрузка и предотвращение повторного действия

Кнопка и диалог описывают действия и контекст. Теперь добавим ожидание операции в Button и отдельную историю сохранения. Цель состоит в двух связанных механизмах: показать занятость и предотвратить повторное действие через пользовательский интерфейс. Мы не будем считать отключённую кнопку защитой сервера от всех повторных запросов; настоящий сетевой контракт остаётся у приложения.

До этого состояния кнопки были статическими: обычная, вторичная, опасная и отключённая. Операция сохранения вводит время. В момент нажатия данные ещё не сохранены, затем появляется ожидание, наконец результат. Если обработчик позволяет продолжать нажимать, пользователь может создать несколько одинаковых операций. Если интерфейс просто молчит, человек не знает, произошёл ли клик. Это две разные проблемы, которые следует решать согласованно.

Пропс занятости

В ButtonProps добавлен необязательный pending. По умолчанию он false, поэтому прежние потребители продолжают работать. Компонент вычисляет нативный disabled как сочетание внешней недоступности и занятости. Если приложение передало disabled: true, окончание ожидания не должно внезапно включить действие. Это показывает пользу раздельных причин: состояние операции не стирает остальные ограничения формы.

Полный файл src/Button.tsx для этого состояния:

import type { ButtonHTMLAttributes } from 'react';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  pending?: boolean;
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'regular' | 'compact';
}
export function Button({ pending = false, disabled = false, children, variant = 'primary', size = 'regular', className = '', type = 'button', ...props }: ButtonProps) {
  return <button {...props} disabled={disabled || pending} aria-busy={pending || undefined} type={type} className={`nabor-button nabor-button--${variant} nabor-button--${size} ${className}`}>{pending ? <>Сохраняем…</> : children}</button>;
}

Полный файл src/SaveDemo.stories.tsx для этого состояния:

import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Button } from './Button';
function SaveDemo() { const [pending,setPending]=useState(false); const [saved,setSaved]=useState(false); async function save() { if(pending)return; setPending(true); setSaved(false); try { await new Promise<void>(resolve=>setTimeout(resolve,800)); setSaved(true); } finally { setPending(false); } } return <><Button pending={pending} onClick={save}>Сохранить</Button><p role="status">{saved?'Изменения сохранены':''}</p></>; }
const meta = { title: 'Nabor/Examples/Save', component: SaveDemo } satisfies Meta<typeof SaveDemo>;
export default meta;
export const Interactive: StoryObj<typeof meta> = {};

Во время ожидания компонент показывает «Сохраняем…» и устанавливает aria-busy. Эта семантика дополняет изменение подписи, однако не является универсальным объявлением результата операции. Отдельное сообщение role="status" в демонстрации позже покажет завершение. Не надо добавлять все возможные ARIA-флаги ради впечатления доступности: каждый атрибут должен соответствовать фактическому состоянию. Если операция не сохранение, потребителю может понадобиться более общий текст занятости.

Наш текущий Button имеет учебную подпись ожидания, связанную с общим примером формы. Для публичной универсальной библиотеки логично расширить контракт на pendingLabel, оставив явное значение по умолчанию. Это отдельное проектное решение, а не необходимость сразу добавлять несколько десятков локализационных пропсов. Мы сначала изучаем механизм на одном действии, чтобы причина изменения была видна в простом исходнике.

Демонстрация с локальным временем

SaveDemo хранит pending и saved через useState. При начале операции прежнее успешное сообщение убирается, занятость включается. Внутри try ожидается Promise с таймером на 800 миллисекунд; finally сбрасывает занятость. Таймер является имитацией задержки, а не HTTP-запросом и не измеренной скоростью приложения. Ни сервер, ни файл организации в этом примере не изменяются.

finally важен потому, что освобождение интерфейса должно происходить при завершении любого пути операции. Если реальный запрос завершится ошибкой, кнопка не должна остаться заблокированной навсегда. Но finally сам по себе не объясняет ошибку пользователю. Для настоящего приложения потребуется catch с понятным результатом и сохранением введённых данных. Следующий урок введёт сообщения, а общий пример формы появится после их согласования.

В начале обработчика есть проверка if (pending) return. Она выражает дополнительную защиту текущей демонстрационной операции. Однако React-state не является распределённым замком. Сервер может получить повтор из другой вкладки, после сетевого сбоя или из другого клиента. Когда создание объекта должно быть идемпотентным, эту задачу решают на серверном уровне с соответствующим ключом и контрактом. UI уменьшает случайные повторения, но не заменяет серверную гарантию.

Промежуточное состояние как самостоятельная история

История Pending передаёт флаг непосредственно. Она не ждёт клика и таймера. Это полезно для изучения геометрии и подписи в состоянии ожидания. Интерактивная история SaveDemo показывает переход во времени. Эти примеры дополняют друг друга: первый фиксирует один кадр, второй объясняет последовательность. Не пытайтесь получить воспроизводимое описание состояния только остановкой интерфейса в удачный момент.

Если подпись ожидания короче или длиннее обычной, размеры кнопки могут измениться. Решение зависит от контекста: можно сохранять минимальную ширину контейнером либо допустить естественную геометрию. Библиотека не обязана иметь фиксированную ширину для любого текста. Важнее, чтобы изменение не сдвигало опасно соседние действия и сохраняло читаемое сообщение. Длинные подписи из прошлой истории пригодятся для самостоятельного дальнейшего анализа.

Жизненный цикл и границы примера

Если пользователь закрывает историю во время задержки, демонстрация не управляет настоящим запросом, потому что его нет. В реальном приложении уход с экрана может потребовать отмены запроса или игнорирования устаревшего результата. Это задача компонента страницы либо сервисного слоя. Добавлять внутрь библиотечной кнопки AbortController для неизвестной операции неправильно: элемент не владеет сетевым процессом и не знает его семантики.

Так же не следует включать таймер скрыто в сам Button. Компонент должен получать актуальный pending от владельца операции. Если он сам решает, что через секунду сохранение завершено, интерфейс может показать успех раньше ответа сервера. Наш таймер находится только в истории, где имитация явно названа. Такая граница сохраняет библиотечный элемент пригодным для настоящего HTTP, локального вычисления и подтверждения пользователя.

При будущем самостоятельном просмотре ожидается последовательность: обычная кнопка, занятая отключённая кнопка, снова обычная кнопка и сообщение о завершении. Автор не выполнял эту последовательность и не записывал длительность. Это честно обозначенный контракт, который предстоит подтвердить отдельно. Следующий урок добавит семантику ошибок и пустых результатов, чтобы приложение объясняло человеку не только ожидание, но и различные исходы операции.

Состояние ошибки не является занятостью

При отказе операции pending заканчивается, но это не равнозначно успеху. Нужно сохранить отдельный результат, который назовёт проблему и возможный следующий шаг. Если держать кнопку занятой после ошибки, пользователь не сможет повторить исправленное действие. Если показать успех в finally, интерфейс будет лгать при любом исключении. finally предназначен для освобождения состояния, а исход операции устанавливается отдельной логикой.

В составной форме также нельзя применять результат старого запроса к уже изменённому вводу без выбора политики. Наш пример запрещает редактирование во время ожидания и тем самым упрощает согласование. Более гибкое приложение может хранить номер версии отправки. Кнопка библиотеки остаётся отображением переданного состояния и не должна угадывать, какой ответ теперь относится к текущему тексту пользователя.

Занятость не должна скрывать действие необъяснимым пустым прямоугольником. Даже без анимированной иконки понятная подпись сообщает, какая операция продолжается. Если позднее появится spinner, он дополнит текст и выбранную семантику, а не заменит весь пользовательский договор декоративным движением.

Оглавление серии, общая подборка и полный архив исходников. Для этого урока выберите lesson-07.

Первичные источники: Storybook CSF, React Vite integration, Аргументы историй, Темы и globals, Декораторы, Accessibility addon, Автоматическая документация, React useId, HTML dialog.

Редакционный статус: локальный черновик. Учебные команды, приложение и проверки не запускались; описанные результаты ожидаемые. Профиль версий закреплён, разрешённый lockfile не создавался.