Все мы любим быстрые интерфейсы. Когда пользователь нажимает "Лайк" или "Добавить в корзину", он хочет видеть результат мгновенно, а не смотреть на спиннер, ожидая ответа сервера. Это называется 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
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