Все мы любим быстрые интерфейсы. Когда пользователь нажимает "Лайк" или "Добавить в корзину", он хочет видеть результат мгновенно, а не смотреть на спиннер, ожидая ответа сервера. Это называется Optimistic UI. Мы "оптимистично" предполагаем, что сервер ответит ОК, и обновляем интерфейс сразу.
Но что, если сервер ответит ошибкой?
В императивном подходе (Promise/async-await) это неизбежно приводит к состоянию гонки и дублированию логики отката в каждом catch блоке. Код превращается в лапшу, которую страшно поддерживать.
Я покажу, как решить эту задачу декларативно, используя архитектуру Unidirectional Data Flow на чистом RxJS, без использования тяжелых стейт-менеджеров.
Чтобы не усложнять пример, возьмем обычный счетчик. Задача: обновлять цифру мгновенно, сохранять на бэкенде асинхронно, и откатывать значение, если бэкенд упал.
Проблема: Императивная лапша
Обычно (плохая) реализация выглядит так:
Изменяем переменную count++.
Шлем запрос на сервер.
В catchError: ой, ошибка, делаем count--.
Проблема этого подхода - Race Conditions.
Если пользователь нажмет кнопку 5 раз подряд, а 3-й запрос упадет с ошибкой, в каком состоянии окажется ваш счетчик? Скорее всего, данные разъедутся с сервером. Логика отката смешивается с логикой обновления.
Нам нужна архитектура, где состояние - это поток, а не переменная.
Архитектура решения
Мы разделим логику на три слоя, используя RxJS потоки:
Intent (Намерения): Поток действий пользователя (нажал "+1").
State (Состояние): Единый источник правды, вычисляемый через scan.
Side Effects (Эффекты): Взаимодействие с API и компенсация ошибок.
Ключевой момент - Компенсация.
Если API возвращает ошибку, мы не "откатываем переменную" вручную. Мы создаем новое действие, обратное предыдущему, и скармливаем его потоку состояния.
Действие: +1
Успешное Сохранение
Действие: +1
...ошибка API...
Компенсация: -1
Action: (+1) -------> (+1) ----------------->
\ \
State: (0) -> (1) ---> (2) -> [API Error!] -> (1)
\ \ /
Side Effect: [HTTP]-- [HTTP] --/ (Compensation -1)
Для системы (State) нет разницы, нажал пользователь кнопку "минус" или это сработала авто-компенсация. Это унифицирует поток данных.
1. Источники изменений (Sources)
У нас есть два источника правды:
update$ - действия пользователя.
correction$ - действия системы (откаты при ошибках).
Мы объединяем их в единый поток изменений delta$.
// +1 или -1 от кнопок
public update$ = new Subject<number>();
// Скрытый поток для откатов
private correction$ = new Subject<number>();
// Единый поток изменений
private delta$ = merge(this.update$, this.correction$);
2. Состояние (State)
Забудьте про this.count = 0. Состояние должно жить внутри потока.
Используем оператор scan (аналог Array.reduce во времени) и shareReplay(1).
public count$ = this.delta$.pipe(
startWith(0),
// Редьюсер: State + Delta = New State
scan((acc, delta) => acc + delta, 0),
// ВАЖНО: Multicasting. Гарантирует, что новые подписчики
// получат последнее актуальное значение мгновенно.
shareReplay(1)
);
3. Эффекты и Синхронизация
Самое главное: нам нужно отправлять данные на сервер, но не блокировать UI.
Этот поток (serverSync$) работает параллельно.
private serverSync$ = this.update$.pipe(
// Слушаем ТОЛЬКО действия юзера
// Берем актуальное состояние, которое УЖЕ обновилось благодаря
// синхронному scan и shareReplay(1)
withLatestFrom(this.count$),
// concatMap гарантирует порядок запросов
concatMap(([delta, state]) => {
return this.fakeApiSave(state).pipe(
// Если все ОК - игнорируем результат, UI уже обновлен
ignoreElements(),
catchError((err: Error) => {
// ПАТТЕРН КОМПЕНСАЦИИ
// 1. Сообщаем об ошибке пользователю
this.errorState$.next(err.message);
// 2. Кидаем "обратную" дельту в поток изменений
// Если было +1, кидаем -1. scan автоматически пересчитает State.
this.correction$.next(-delta);
return EMPTY; // Гасим ошибку, чтобы основной стрим sync$ не умер
})
)
})
);
Обратите внимание: благодаря синхронной природе RxJS и shareReplay(1), в withLatestFrom мы получаем уже обновленное оптимистичное состояние, которое отправляем на сервер.
Почему concatMap? Это осознанный архитектурный выбор.
concatMap: Выстраивает запросы в честную очередь. Если пользователь кликнет 5 раз, уйдет 5 запросов последовательно. Это гарантирует строгую консистентность.
switchMap: Отменил бы предыдущие запросы. Это быстрее, но сложнее в обработке ошибок: если мы отменили запрос, нужно ли делать откат? В простых CRUD операциях concatMap надежнее.
4. Сборка в компоненте
Мы используем takeUntilDestroyed, чтобы запустить наш процесс синхронизации при создании компонента и автоматически убить его при уничтожении. Никаких ручных ngOnDestroy.
@Component({
selector: 'app-root',
template: `
<div class="counter">
<h1>Count: {{ count$ | async }}</h1>
@if (error$ | async; as err) {
<div class="error">{{ err }}</div>
}
<button (click)="update$.next(1)">Increment (+)</button>
<button (click)="update$.next(-1)">Decrement (-)</button>
</div>
`,
standalone: true,
imports: [AsyncPipe],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CounterComponent {
// ... объявления потоков ...
constructor() {
// Запуск синхронизации
this.serverSync$.pipe(takeUntilDestroyed()).subscribe();
}
}
Итого
Что мы получили, отказавшись от императивного подхода?
Мгновенный отклик: UI обновляется синхронно в момент клика.
Надежность: При ошибке сервера состояние гарантировано возвращается к корректному значению благодаря математике внутри scan. Никаких "разъехавшихся" данных.
Чистота: Никаких мутаций внешних переменных
(this.val = x). Вся логика инкапсулирована в пайпах.Масштабируемость: Хотите добавить логирование, аналитику или debounce? Просто добавьте еще один оператор в pipe.
Ссылка на GitHub с примером: https://github.com/AlekseyVY/ngx-reactive-optimistic-ui
Комментарии (4)

frostsumonner
06.12.2025 22:48Пример с счетчиком слишком примитивный. Пусть в форме есть два текстовых поля (имя и кодовое слово) и доп. условие что кодовое слово не может содержать имя. Теперь представим, что пользователь сделал:
1. Ввел имя Bob
2. Ввел кодовое слово Secret
3. Поменял имя на John
4. Поменял кодовое слово на SecretBob
Допустим 3й запрос упал, а 4й еще в процессе - вот даже на уровне концепции объясни, что ты покажешь пользователю и что тут "Компенсация".
Получится просто и понятно? Если нет, то зачем вообще этот огород городить - есть изменение - есть запрос на сохранение, а при ошибке просто её показали и предложили ~перезагрузить страницу.
AlekseyVY Автор
06.12.2025 22:48Привет, паттерны и структуры данных нельзя натягивать на любой кейс.
Описанный в статье подход предназначен для атомарных, независимых действий: лайк, каунтер, тоггл, добавление в корзину. Там, где UX требует мгновенного отклика и блокировать интерфейс нельзя.
Твой пример это форма со связанным состоянием, где валидация одного поля зависит от другого. В таком сценарии Optimistic UI в принципе противопоказан. Здесь достаточно стандартной обработки ошибки без компенсаций.
Не стоит забивать гвозди микроскопом. Статья решает проблему Race Conditions именно для потока атомарных событий.
cmyser
Pessimistic UI
Интерфейс блокируется, пока изменения не синхронизируются.
Ухудшение отзывчивости пропорционально задержке сети и времени обработки запроса сервером.
Пользователь не теряет контекст внесения изменений.
Пользователь всегда понимает когда и как завершился каждый эпизод синхронизации.
Синхронизация может быть инициирована лишь пользователем в явном виде.
Optimistic UI
Интерфейс показывает предположительное финальное состояние, проводя синхронизацию в фоне.
Мгновенная обратная связь на действия пользователя в лучшем случае.
Запуск синхронизации возможен без явного действия пользователя.
Действия пользователя могут быть внезапно отменены через непредсказуемый промежуток времени.
Если что-то пошло не так, а пользователь уже ушёл, то сложно адекватно объяснить ему, что произошло и что ему делать.
Пользователь не понимает, когда изменения действительно синхронизированы и можно уходить.
Realistic UI
Интерфейс показывает все промежуточные состояния: актуально, идёт синхронизация и тп.
Мгновенная обратная связь как на действия пользователя, так и на изменения состояния процесса синхронизации.
Запуск синхронизации возможен без явного действия пользователя.
Пользователь всегда понимает, когда изменения действительно синхронизированы и можно уходить.
У каждого процесса синхронизации есть предсказуемое место в UI, где выводится его состояние.
https://github.com/hyoo-ru/mam_mol/wiki/Optimistic-vs-Pessimistic-vs-Realistic-UI Источник.
nin-jin
Тут более актуальный материал: https://mol.hyoo.ru/#!section=docs/=irbep1_kgl69e