Create your own
Lesson illustration

Optimistic Updates with Rollback

Hello! Welcome back to our advanced RxJS course.

In our last lesson, we built a Redux-like synchronous store using the scan operator, creating a single source of truth for our application's state. Today, we'll address the next critical challenge: managing asynchronous side effects, like API calls, that mutate this state.

This lesson focuses on a powerful UI pattern called optimistic updates. You'll learn how to make your application feel incredibly responsive by updating the UI before a server request completes, and how to gracefully handle failures by rolling back the changes.

By the end of this 60-minute lesson, you will be able to implement a robust optimistic update and rollback strategy using the RxJS operators tap, concat, and catchError.

1. The Challenge: Asynchronous State Changes

When a user performs an action that requires a server call (e.g., adding a to-do item, updating a profile), we have two primary ways to handle the UI update:

  1. Pessimistic Update: This is the "safe" approach. We show a loading spinner, send the request, wait for the server to confirm success, and only then update the UI. It's reliable but can feel slow.
  2. Optimistic Update: This is the "fast" approach. We immediately update the UI as if the request were guaranteed to succeed. We send the request in the background. If it succeeds, we do nothing more. If it fails, we must roll back the UI to its previous state and inform the user.

Optimistic updates provide a superior user experience by making the application feel instantaneous. The complexity lies in correctly managing the failure case. RxJS provides the perfect toolset to handle this declarative.

2. The RxJS Toolkit for Optimistic Updates

To implement this pattern, we'll orchestrate a few key operators. The core idea is to perform a side effect (the UI update) and then trigger an async action, with a plan for what to do if that action fails.

First, let's establish the foundation of error handling in RxJS.

RxJs Error Handling: Complete Practical Guide

To understand rollbacks, we first need to understand how errors are handled. This article from the Angular University blog, 'RxJs Error Handling: Complete Practical Guide', provides an excellent foundation.

Please read the first two sections: 'The Observable Contract and Error Handling' and 'How does catchError work?'. Focus on two key ideas: An observable stream terminates permanently upon an error. The catchError operator intercepts an error and returns a replacement observable, allowing the main stream to continue.

With that foundation, here are the operators we'll use for our pattern:

  • tap: We use tap to perform the optimistic update as a side effect. When an action to change data comes in, we'll use tap to immediately push the new, "optimistic" state to our store before the API call even begins.
  • A Flattening Operator (e.g., mergeMap, concatMap): After the tap, we need to trigger the API call, which is itself an observable. A flattening operator like mergeMap subscribes to this inner (API) observable and merges its emissions back into the main stream.
  • catchError: This is our safety net and the heart of the rollback. We place it directly on the API call observable. If the API call fails, catchError intercepts the error, preventing it from destroying the main stream. Inside catchError, we perform the rollback logic—reverting the state to what it was before our optimistic tap.
  • concat: While catchError handles the rollback, what if the rollback itself involves multiple sequential steps (e.g., update state, then show a notification)? The concat creation function is perfect for sequencing observables within a catchError block to ensure rollback steps happen in a specific order.

3. Building the Pattern: A Code Walkthrough

Let's look at a practical, real-world implementation. The following code snippet is from a generic CRUD utility and demonstrates the optimistic update pattern perfectly. While it's from an Angular project, the RxJS logic is universal.

Angular Crud Resource POC

This Gist by Tomas Trajan, 'Angular Crud Resource POC', contains excellent, production-grade examples of optimistic updates for create, update, and delete operations. We'll focus on the update function.

Scan through the update function's implementation. You don't need to understand every line, but focus on the overall structure: A map operator prepares the data, saving a prevVersionOfItem. The resource.update call that happens before the http.put call. This is the optimistic update. The catchError block on the http.put call, which uses the saved prevVersionOfItem to perform the rollback.

Let's distill that logic into a clear, step-by-step example. Imagine we have a store managing a list of tasks.

The Goal: Optimistically update a task's title and roll back on API failure.

Our Store:

interface Task {
  id: number;
  title: string;
}

// Our single source of truth for the list of tasks
const tasks$ = new BehaviorSubject<Task[]>([
  { id: 1, title: 'Learn RxJS' },
  { id: 2, title: 'Build an app' },
]);

// Action stream to trigger updates
const updateTaskAction$ = new Subject<Task>();

Now, let's create the main pipeline that listens for update actions and applies the optimistic pattern.

// main.ts
import { Subject, BehaviorSubject, of, concat } from 'rxjs';
import { mergeMap, map, tap, catchError, ignoreElements } from 'rxjs/operators';

// A fake API call that will fail for tasks with an odd ID
function simulatedApiUpdate(task: Task): Observable<Task> {
  console.log(`%cAPI: Attempting to update task ${task.id} to "${task.title}"...`, 'color: blue');
  return new Observable(subscriber => {
    setTimeout(() => {
      if (task.id % 2 !== 0) {
        console.error(`%cAPI: FAILED to update task ${task.id}.`, 'color: red');
        subscriber.error({ message: 'Update failed on the server!' });
      } else {
        console.log(`%cAPI: SUCCESS updating task ${task.id}.`, 'color: green');
        subscriber.next(task);
        subscriber.complete();
      }
    }, 1000);
  });
}

// The main logic pipeline
updateTaskAction$.pipe(
  // Use mergeMap to handle each update action concurrently
  mergeMap(updatedTask => {
    let previousTasks: Task[] | null = null;

    // 1. THE OPTIMISTIC UPDATE
    // Use `tap` to perform a side effect: update the BehaviorSubject immediately.
    // We save the old state so we can revert to it on failure.
    const optimisticUpdate$ = of(updatedTask).pipe(
      tap(task => {
        previousTasks = tasks$.getValue(); // Save the state *before* the change
        const newTasks = previousTasks.map(t => t.id === task.id ? task : t);
        console.log(`%cUI: Optimistically updating task ${task.id} to "${task.title}"`, 'color: orange');
        tasks$.next(newTasks);
      })
    );

    // 2. THE API CALL
    const apiCall$ = simulatedApiUpdate(updatedTask);

    // 3. THE ROLLBACK
    // We combine the optimistic update and the API call.
    // `catchError` is placed on the API call to handle the rollback.
    return concat(optimisticUpdate$, apiCall$).pipe(
      // We only care about errors, so we can ignore successful emissions from this inner chain.
      ignoreElements(), 
      catchError((error, caught) => {
        console.error(`%cUI: Rolling back update for task ${task.id}`, 'color: red; font-weight: bold');
        
        // Perform the rollback using the saved state
        if (previousTasks) {
          tasks$.next(previousTasks);
        }
        
        // We've handled the error and rolled back the state.
        // Return an EMPTY observable to prevent the error from propagating further.
        return EMPTY;
      })
    );
  })
).subscribe();

// Let's see it in action
tasks$.subscribe(currentTasks => {
  console.log('--- Current Tasks State ---');
  console.table(currentTasks);
  console.log('---------------------------\n');
});

// --- TRIGGER ACTIONS ---

// This one will fail
console.log('Dispatching update for task 1 (will fail)...');
updateTaskAction$.next({ id: 1, title: 'Learn RxJS Patterns!!!' });

// This one will succeed (dispatch after a delay to see them run)
setTimeout(() => {
  console.log('Dispatching update for task 2 (will succeed)...');
  updateTaskAction$.next({ id: 2, title: 'Build an awesome app' });
}, 3000);

Dissecting the Logic:

  1. mergeMap: It allows us to process multiple update requests concurrently. For each incoming updatedTask, it creates a new inner observable chain.
  2. optimisticUpdate$: We create a small observable that uses tap to do the dirty work: it gets the current state from tasks$, saves it in previousTasks, computes the new state, and pushes it to tasks$.
  3. concat(optimisticUpdate$, apiCall$): This is key. concat ensures that optimisticUpdate$ runs to completion before apiCall$ is subscribed to. This guarantees our UI is updated before the network request starts.
  4. catchError: This is placed on the combined stream. If apiCall$ emits an error, concat forwards that error, and catchError catches it.
  5. The Rollback: Inside catchError, we log the error and, most importantly, call tasks$.next(previousTasks), restoring the state to what it was before the optimistic update.
  6. return EMPTY: After handling the error, we return an empty observable. This signals to the mergeMap that this inner flow has completed successfully (from its perspective), allowing the overall updateTaskAction$ stream to stay alive and process future actions.

4. Advanced Rollback with concat

In our example, the rollback was a single action. What if you need to perform a sequence of actions, like reverting state and then showing a notification? The concat function is perfect for this, as shown in the following video.

I only ever use *these* RxJS operators to code reactively

This clip from Joshua Morony's channel shows a creative use of concat inside a catchError block to sequence multiple side effects during a rollback.

Watch the segment from 21:20 to 22:25. Notice how he uses concat to return a new stream that first emits a null value (to clear local data) and then re-throws the error. We can adapt this to sequence multiple rollback steps.

Applying that idea, our catchError block could be enhanced like this:

catchError((error, caught) => {
  console.error(`UI: Rolling back update for task ${task.id}`);
  
  const rollbackState$ = of(true).pipe(tap(() => {
    if (previousTasks) tasks$.next(previousTasks);
  }));

  const showNotification$ = of(true).pipe(tap(() => {
    // In a real app, you'd call a notification service here
    console.log(`%cNOTIFICATION: Could not save task "${task.title}"`, 'background: #fff0f0; color: red;');
  }));

  // Use concat to run rollback actions in sequence
  return concat(rollbackState$, showNotification$).pipe(
    // Once sequencing is done, swallow the error
    ignoreElements()
  );
})

This demonstrates how you can compose complex, ordered rollback logic within a single, declarative catchError block.

Conclusion

You have now learned one of the most impactful patterns for creating modern, responsive web applications with RxJS. By updating the UI first and handling errors gracefully, you can make network latency virtually disappear from the user's perspective.

Key Takeaways:

  • Optimistic updates improve perceived performance by updating the UI before an async operation completes.
  • The pattern relies on a core sequence:
    1. Use tap for the immediate, optimistic state change (the side effect).
    2. Use a flattening operator like mergeMap to introduce the async operation (e.g., an API call).
    3. Place catchError on the async operation's stream to handle failures.
    4. Inside catchError, perform the rollback by reverting the state to its pre-optimistic value.
  • The concat operator is a powerful tool for sequencing operations, both for the initial update/API call flow and for orchestrating multi-step rollbacks within catchError.

In our next lesson, we will explore another advanced state management technique: building undo/redo functionality for state changes using the buffer and scan operators. This will further showcase how RxJS streams can model complex state interactions over time.

Can't find a good explanation? Sign up and we'll make it for you

Sign up