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-optimisticNote: This plugin requires
@spoosh/plugin-invalidationas 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
| Option | Type | Description |
|---|---|---|
optimistic | (cache) => builder | builder[] | Callback to define optimistic updates |
Builder Methods
Chain methods to configure optimistic updates:
| Method | Description |
|---|---|
.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
| Usage | Description |
|---|---|
.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:
| Property | Type | Description |
|---|---|---|
meta().isOptimistic | boolean | true if current data is from an optimistic update |