Все мы любим быстрые интерфейсы. Когда пользователь нажимает "Лайк" или "Добавить в корзину", он хочет видеть результат мгновенно, а не смотреть на спиннер, ожидая ответа сервера. Это называется Optimistic UI. Мы "оптимистично" предполагаем, что сервер ответит ОК, и обновляем интерфейс сразу.

Но что, если сервер ответит ошибкой?

В императивном подходе (Promise/async-await) это неизбежно приводит к состоянию гонки и дублированию логики отката в каждом catch блоке. Код превращается в лапшу, которую страшно поддерживать.

Я покажу, как решить эту задачу декларативно, используя архитектуру Unidirectional Data Flow на чистом RxJS, без использования тяжелых стейт-менеджеров.

Чтобы не усложнять пример, возьмем обычный счетчик. Задача: обновлять цифру мгновенно, сохранять на бэкенде асинхронно, и откатывать значение, если бэкенд упал.

Проблема: Императивная лапша

Обычно (плохая) реализация выглядит так:

  1. Изменяем переменную count++.

  2. Шлем запрос на сервер.

  3. В catchError: ой, ошибка, делаем count--.

Проблема этого подхода - Race Conditions.
Если пользователь нажмет кнопку 5 раз подряд, а 3-й запрос упадет с ошибкой, в каком состоянии окажется ваш счетчик? Скорее всего, данные разъедутся с сервером. Логика отката смешивается с логикой обновления.

Нам нужна архитектура, где состояние - это поток, а не переменная.

Архитектура решения

Мы разделим логику на три слоя, используя RxJS потоки:

  1. Intent (Намерения): Поток действий пользователя (нажал "+1").

  2. State (Состояние): Единый источник правды, вычисляемый через scan.

  3. 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)

У нас есть два источника правды:

  1. update$ - действия пользователя.

  2. 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();
    }
}

Итого

Что мы получили, отказавшись от императивного подхода?

  1. Мгновенный отклик: UI обновляется синхронно в момент клика.

  2. Надежность: При ошибке сервера состояние гарантировано возвращается к корректному значению благодаря математике внутри scan. Никаких "разъехавшихся" данных.

  3. Чистота: Никаких мутаций внешних переменных (this.val = x). Вся логика инкапсулирована в пайпах.

  4. Масштабируемость: Хотите добавить логирование, аналитику или debounce? Просто добавьте еще один оператор в pipe.

Ссылка на GitHub с примером: https://github.com/AlekseyVY/ngx-reactive-optimistic-ui

Комментарии (2)


  1. cmyser
    06.12.2025 22:48

    Pessimistic UI

    Интерфейс блокируется, пока изменения не синхронизируются.

    • Ухудшение отзывчивости пропорционально задержке сети и времени обработки запроса сервером.

    • Пользователь не теряет контекст внесения изменений.

    • Пользователь всегда понимает когда и как завершился каждый эпизод синхронизации.

    • Синхронизация может быть инициирована лишь пользователем в явном виде.

    Optimistic UI

    Интерфейс показывает предположительное финальное состояние, проводя синхронизацию в фоне.

    • Мгновенная обратная связь на действия пользователя в лучшем случае.

    • Запуск синхронизации возможен без явного действия пользователя.

    • Действия пользователя могут быть внезапно отменены через непредсказуемый промежуток времени.

    • Если что-то пошло не так, а пользователь уже ушёл, то сложно адекватно объяснить ему, что произошло и что ему делать.

    • Пользователь не понимает, когда изменения действительно синхронизированы и можно уходить.

    Realistic UI

    Интерфейс показывает все промежуточные состояния: актуально, идёт синхронизация и тп.

    • Мгновенная обратная связь как на действия пользователя, так и на изменения состояния процесса синхронизации.

    • Запуск синхронизации возможен без явного действия пользователя.

    • Пользователь всегда понимает, когда изменения действительно синхронизированы и можно уходить.

    • У каждого процесса синхронизации есть предсказуемое место в UI, где выводится его состояние.

    https://github.com/hyoo-ru/mam_mol/wiki/Optimistic-vs-Pessimistic-vs-Realistic-UI Источник.


    1. nin-jin
      06.12.2025 22:48

      Тут более актуальный материал: https://mol.hyoo.ru/#!section=docs/=irbep1_kgl69e