Guide
Quick start covered the basics. This guide adds detail views, error handling, mutations, and optimistic updates.
Architecture
Section titled “Architecture”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.
Domain pattern
Section titled “Domain pattern”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:
- Components should not inject an API service directly.
- Keep HTTP logic in the API service, not the Store.
- The Store should not instantiate a
DataCache; it readsthis.#api.cache. - 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 |
Recipes
Section titled “Recipes”These recipes continue with the API service and list Store from Quick start.
1. Fetch one resource by ID
Section titled “1. Fetch one resource by ID”Returning undefined from params keeps the resource idle until an ID exists.
@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) }}2. Display cached data in templates
Section titled “2. Display cached data in templates”@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:
@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 /> }}3. Invalidate after a mutation
Section titled “3. Invalidate after a mutation”cachedMutation() replaces hand-written pending and error signals, try/catch,
and post-success invalidation with one declarative definition.
@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']], })}<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:
@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.
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 },})@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>}5. Combine loading states
Section titled “5. Combine loading states”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.