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-optimistic @spoosh/plugin-invalidation

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

Usage

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

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

Basic Optimistic Update

Use the chained DSL to define optimistic updates:

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

deletePost.trigger({
  params: { id },
  optimistic: (api) =>
    api("posts")
      .GET()
      .UPDATE_CACHE((posts) => posts.filter((p) => p.id !== id)),
});

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

Update with Response Data

Use ON_SUCCESS to update cache with the actual response:

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

createPost.trigger({
  body: { title: "New Post", content: "..." },
  optimistic: (api) =>
    api("posts")
      .GET()
      .ON_SUCCESS()
      .UPDATE_CACHE((posts, newPost) => [newPost!, ...posts]),
});

Multiple Targets

Update multiple caches at once by returning an array:

deletePost.trigger({
  optimistic: (api) => [
    api("posts")
      .GET()
      .UPDATE_CACHE((posts) => posts.filter((p) => p.id !== id)),
    api("stats")
      .GET()
      .UPDATE_CACHE((stats) => ({ ...stats, count: stats.count - 1 })),
  ],
});

Filter by Request Params

Use WHERE to only update specific cache entries:

createPost.trigger({
  optimistic: (api) =>
    api("posts")
      .GET()
      .WHERE((request) => request.query?.page === 1)
      .UPDATE_CACHE((posts, newPost) => [newPost!, ...posts]),
});

Options

Per-Request Options

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

Builder Methods (DSL)

Chain methods to configure optimistic updates:

MethodDescription
.GET()Select the GET endpoint to update
.WHERE(fn)Filter which cache entries to update
.UPDATE_CACHE(fn)Update cache immediately (default timing)
.ON_SUCCESS()Switch to onSuccess timing mode
.NO_ROLLBACK()Disable automatic rollback on error
.ON_ERROR(fn)Error callback

Timing Modes

UsageDescription
.UPDATE_CACHE(fn)Immediate - Update cache instantly before request completes. Rollback on error.
.ON_SUCCESS().UPDATE_CACHE(fn)On Success - Wait for successful response, then update cache. fn receives response as 2nd arg.

Result

The injectRead function returns plugin metadata via the meta property:

PropertyTypeDescription
meta.isOptimisticbooleantrue if current data is from an optimistic update
@Component({
  selector: "app-post-list",
  template: `
    @if (posts.meta().isOptimistic) {
      <span>Saving...</span>
    }
    <ul>
      @for (post of posts.data(); track post.id) {
        <li>{{ post.title }}</li>
      }
    </ul>
  `,
})
export class PostListComponent {
  posts = injectRead((api) => api("posts").GET());
}

Auto-Invalidation Behavior

By default, when using optimistic updates, invalidation mode is set to "none" for that request to prevent immediate cache invalidation from overwriting your optimistic data.

If you want auto-invalidation to still happen, set it explicitly:

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

// Invalidate all related tags (full hierarchy)
deletePost.trigger({
  optimistic: (api) =>
    api("posts")
      .GET()
      .UPDATE_CACHE((posts) => [...posts]),
  invalidate: "all",
});

// Invalidate only the exact endpoint tag
deletePost.trigger({
  optimistic: (api) =>
    api("posts")
      .GET()
      .UPDATE_CACHE((posts) => [...posts]),
  invalidate: "self",
});

// Target specific tags manually
deletePost.trigger({
  optimistic: (api) =>
    api("posts")
      .GET()
      .UPDATE_CACHE((posts) => [...posts]),
  invalidate: ["posts", "stats", "dashboard"],
});

On this page