Skip to content

Guide

Quick start covered the basics. This guide adds detail views, error handling, mutations, and optimistic updates.

Component → route-scoped Store → root API service + DataCache → server
↑ │ │
└──────── signals ─────── cachedResource() / cachedMutation()

The component reads signals from a route-scoped Store. The Store composes cachedResource() and cachedMutation(). A root-scoped API service owns the long-lived DataCache and the HTTP calls.

A recommended structure for most features:

Step File Role Scope
1 order.api.ts HTTP + cache singleton
2 order-list.store.ts cached resource + mutations route
3 order-list.component.ts inject Store, read signals view

Guidelines:

  1. Components should not inject an API service directly.
  2. Keep HTTP logic in the API service, not the Store.
  3. The Store should not instantiate a DataCache; it reads this.#api.cache.
  4. Mutations invalidate via invalidateKeys. The Store owns that behavior, not the API service.

Naming conventions:

Concept Class File
API service OrderApi order.api.ts
List store OrderListStore order-list.store.ts
Detail store OrderDetailStore order-detail.store.ts

These recipes continue with the API service and list Store from Quick start.

Returning undefined from params keeps the resource idle until an ID exists.

order-detail.store.ts
@Injectable()
export class OrderDetailStore {
readonly #api = inject(OrderApi)
readonly #id = signal<string | null>(null)
readonly order = cachedResource({
cache: this.#api.cache,
cacheKey: params => ['order', 'details', params.id],
params: () => {
const id = this.#id()
return id ? { id } : undefined // undefined = idle, loader doesn't run
},
loader: ({ params }) => this.#api.getById$(params.id),
})
load(id: string) {
this.#id.set(id)
}
}
order-list.component.html
@if (store.orders.isInitialLoading()) {
<app-spinner />
} @else {
<app-order-list [orders]="store.orders.value()" />
}

When the server fails but stale data exists, show both the error and the cached value:

order-list.component.html
@if (store.orders.error()) {
<div class="error-banner">Failed to refresh. Showing cached data.</div>
}
@if (store.orders.isInitialLoading()) {
<app-spinner />
} @else {
@let list = store.orders.value();
@if (list) {
<app-order-list [orders]="list" [stale]="store.orders.isStale()" />
} @else {
<app-empty-state />
}
}

cachedMutation() replaces hand-written pending and error signals, try/catch, and post-success invalidation with one declarative definition.

order-list.store.ts
@Injectable()
export class OrderListStore {
readonly #api = inject(OrderApi)
readonly orders = cachedResource({ /* ... */ })
readonly deleteOrder = cachedMutation({
cache: this.#api.cache,
mutationFn: (id: string) => this.#api.delete$(id),
invalidateKeys: (id) => [['order', 'details', id], ['order', 'list']],
})
}
order-list.component.html
<button
(click)="store.deleteOrder.mutate(order.id)"
[disabled]="store.deleteOrder.isPending()"
>
@if (store.deleteOrder.isPending()) { Deleting... } @else { Delete }
</button>
@if (store.deleteOrder.error()) {
<div class="error-banner">
Delete failed. Please try again.
<button (click)="store.deleteOrder.reset()">Dismiss</button>
</div>
}

When TypeScript needs the result:

order-list.component.ts
@Component({
template: `
<button (click)="onDelete(order.id)" [disabled]="store.deleteOrder.isPending()">
Delete
</button>
`,
})
export class OrderListComponent {
readonly store = inject(OrderListStore)
async onDelete(id: string) {
const result = await this.store.deleteOrder.mutate(id)
if (result !== undefined) {
this.#toast.show('Order deleted')
}
// No try/catch needed: errors land in store.deleteOrder.error()
}
}

4. Update the UI before the server responds

Section titled “4. Update the UI before the server responds”

Optimistic updates apply a change immediately, then roll it back if the server call fails.

order-list.store.ts
readonly updateOrder = cachedMutation({
cache: this.#api.cache,
mutationFn: (args: { id: string; data: Partial<Order> }) =>
this.#api.update$(args.id, args.data),
invalidateKeys: (args) => [['order', 'details', args.id], ['order', 'list']],
// 1. Runs BEFORE the API call
onMutate: (args) => {
const prev = this.orders.value() // snapshot current state
this.orders.update(list => // apply change to UI immediately
(list ?? []).map(o => (o.id === args.id ? { ...o, ...args.data } : o)),
)
return prev // → becomes "context" in onError
},
// 2. Only runs if the API call fails
onError: (_err, _args, context) => {
if (context) this.orders.set(context) // restore the snapshot → UI rolls back
},
})
order-list.component.html
@for (order of store.orders.value(); track order.id) {
<div class="order-row">
<span>{{ order.name }}</span>
<button
(click)="store.updateOrder.mutate({ id: order.id, data: { name: newName } })"
[disabled]="store.updateOrder.isPending()"
>
Save
</button>
</div>
}
@if (store.updateOrder.error()) {
<div class="error-banner">Update failed, changes have been rolled back.</div>
}
order-list.store.ts
readonly isAnythingLoading = anyLoading(
this.orders.isLoading,
this.deleteOrder.isPending,
)

isLoading belongs to cachedResource() and is true during the initial load or background revalidation. isPending belongs to cachedMutation() and is true while the mutation is in flight. Both are Signal<boolean>, so anyLoading() combines them.