Spoosh
Plugins

Optimistic Updates

Instant UI updates with automatic rollback on error

The optimistic plugin updates the UI immediately before the server responds. If the request fails, changes are automatically rolled back.

Installation

npm install @spoosh/plugin-cache @spoosh/plugin-invalidation @spoosh/plugin-optimistic

Note: This plugin requires @spoosh/plugin-invalidation as a peer dependency.

Usage

import { Spoosh } from "@spoosh/core";
import { cachePlugin } from "@spoosh/plugin-cache";
import { deduplicationPlugin } from "@spoosh/plugin-deduplication";
import { invalidationPlugin } from "@spoosh/plugin-invalidation";
import { optimisticPlugin } from "@spoosh/plugin-optimistic";

const spoosh = new Spoosh<ApiSchema, Error>("/api").use([
  cachePlugin({ staleTime: 5000 }),
  deduplicationPlugin(),
  invalidationPlugin(),
  optimisticPlugin(),
]);

Basic Optimistic Delete

Remove an item from a cached list immediately, before the server confirms:

@Component({
  selector: "app-post-list",
  template: `
    @if (meta().isOptimistic) {
      <span>Saving...</span>
    }
    <ul>
      @for (post of data(); track post.id) {
        <li>
          {{ post.title }}
          <button (click)="handleDelete(post.id)">Delete</button>
        </li>
      }
    </ul>
  `,
})
export class PostListComponent {
  private posts = injectRead((api) => api("posts").GET());
  private deletePost = injectWrite((api) => api("posts/:id").DELETE());

  data = this.posts.data;
  meta = this.posts.meta;

  handleDelete(id: number) {
    this.deletePost.trigger({
      params: { id },
      optimistic: (cache) =>
        cache("posts").set((posts) => posts.filter((p) => p.id !== id)),
    });
  }
}

The set function receives the current cached data and returns the optimistically updated data.

Optimistic Create

Prepend a new item to the list before the server responds:

private createPost = injectWrite((api) => api("posts").POST());

submit() {
  this.createPost.trigger({
    body: { title: "Draft Post", content: "..." },
    optimistic: (cache) =>
      cache("posts").set((posts) => [
        { id: Date.now(), title: "Draft Post", content: "..." },
        ...posts,
      ]),
  });
}

Using Response Data

Use confirmed() to update the cache with the actual server response instead of fabricated data:

this.createPost.trigger({
  body: { title: "New Post", content: "..." },
  optimistic: (cache) =>
    cache("posts")
      .confirmed()
      .set((posts, newPost) => [newPost, ...posts]),
});

The second argument to set is the mutation response, available after the server responds successfully.

Both Immediate and Confirmed Updates

You can chain both immediate (optimistic) and confirmed updates in a single call:

this.createPost.trigger({
  body: { title: "New Post", content: "..." },
  optimistic: (cache) =>
    cache("posts")
      .set((posts) => [
        { id: -1, title: "Saving...", content: "..." },
        ...posts,
      ])
      .confirmed()
      .set((posts, newPost) => posts.map((p) => (p.id === -1 ? newPost : p))),
});

Updating Multiple Caches

Return an array to update several cache entries at once:

this.deletePost.trigger({
  params: { id },
  optimistic: (cache) => [
    cache("posts").set((posts) => posts.filter((p) => p.id !== id)),
    cache("stats").set((stats) => ({
      ...stats,
      postCount: stats.postCount - 1,
    })),
  ],
});

Filtering Cache Entries

When the same endpoint is called with different parameters, multiple cache entries exist. Use filter to target specific entries based on their request parameters:

// If you have multiple cached queries:
// - injectRead((api) => api("posts").GET({ query: { page: 1 } }))
// - injectRead((api) => api("posts").GET({ query: { page: 2 } }))
// - injectRead((api) => api("posts").GET({ query: { category: "tech" } }))

// Only prepend to page 1 results
this.createPost.trigger({
  body: newPost,
  optimistic: (cache) =>
    cache("posts")
      .filter((entry) => entry.query?.page === 1)
      .set((posts) => [newPost, ...posts]),
});

Without filter, all cached entries for that endpoint would be updated. The entry parameter in the callback contains the original request's query and params:

// Only update cache for a specific category
this.createPost.trigger({
  body: newPost,
  optimistic: (cache) =>
    cache("posts")
      .filter((entry) => entry.query?.category === newPost.category)
      .set((posts) => [newPost, ...posts]),
});

Detecting Optimistic State

The meta().isOptimistic flag indicates whether displayed data came from an optimistic update:

@Component({
  selector: "app-post-list",
  template: `
    @if (meta().isOptimistic) {
      <div>Changes pending...</div>
    }
    @for (post of data(); track post.id) {
      <app-post-card [post]="post" />
    }
  `,
})
export class PostListComponent {
  private posts = injectRead((api) => api("posts").GET());

  data = this.posts.data;
  meta = this.posts.meta;
}

Controlling Rollback

By default, optimistic updates roll back on error. Use disableRollback() to keep the optimistic state:

this.createPost.trigger({
  optimistic: (cache) =>
    cache("posts")
      .set((posts) => [...posts, newPost])
      .disableRollback(),
});

Handle errors manually with onError:

this.createPost.trigger({
  optimistic: (cache) =>
    cache("posts")
      .set((posts) => [...posts, newPost])
      .onError((error) => {
        this.showToast(`Failed to save: ${error.message}`);
      }),
});

Combining with Invalidation

By default, optimistic updates disable invalidation (invalidate: false) to prevent the server response from overwriting your optimistic data prematurely. To enable invalidation alongside optimistic updates:

private deletePost = injectWrite((api) => api("posts/:id").DELETE());

// In your method:
this.deletePost.trigger({
  params: { id },
  optimistic: (cache) =>
    cache("posts").set((posts) => posts.filter((p) => p.id !== id)),
  // Explicitly enable invalidation with patterns
  invalidate: ["posts", "posts/*"],
});

Options

Per-Request Options

OptionTypeDescription
optimistic(cache) => builder | builder[]Callback to define optimistic updates

Builder Methods

Chain methods to configure optimistic updates:

MethodDescription
.filter(fn)Filter which cache entries to update
.set(fn)Update cache (immediate before .confirmed())
.confirmed()Switch to confirmed mode (update after success)
.disableRollback()Disable automatic rollback on error
.onError(fn)Error callback

Update Modes

UsageDescription
.set(fn)Immediate - Update cache instantly before request completes. Rollback on error.
.confirmed().set(fn)Confirmed - Wait for successful response, then update cache. fn receives response as 2nd arg.
.set(fn).confirmed().set(fn)Both - Immediate update, then replace with confirmed data on success.

Result

The injectRead function returns plugin metadata via the meta signal:

PropertyTypeDescription
meta().isOptimisticbooleantrue if current data is from an optimistic update

On this page