Angular 21+ · Zero dependencies · Signal-native · Tree-shakeable
SWR caching for Angular resource()
Instant navigations, background refreshes, zero spinners.
Every time your user navigates back to a page they already visited, they see a spinner. ziflux eliminates that. One cache layer with stale-while-revalidate (SWR) semantics: return visits are instant, background refreshes are silent. 3 functions, zero dependencies, ~2KB.
npm install ngx-zifluxconst todos = cachedResource({
cache: this.#api.cache,
cacheKey: params => ['todos', params.status],
params: () => this.filters(),
loader: ({ params }) => this.#api.getAll$(params),
})What it feels like
Same app, same actions. One caches.
Quick start #
1 · Install & configure
One provider, two durations.
npm install ngx-zifluximport { provideZiflux } from 'ngx-ziflux'
export const appConfig: ApplicationConfig = {
providers: [
provideZiflux({
staleTime: 30_000, // 30s — data considered fresh
expireTime: 300_000, // 5min — stale data evicted
}),
],
}2 · Add a cache to your API service
Add a DataCache instance to your existing API service. One line.
import { inject, Injectable } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import { DataCache } from 'ngx-ziflux'
@Injectable({ providedIn: 'root' })
export class OrderApi {
readonly cache = new DataCache() // ← this is new
readonly #http = inject(HttpClient)
getAll$(filters: OrderFilters) {
return this.#http.get<Order[]>('/orders', { params: { ...filters } })
}
}3 · Use cachedResource()
Same shape as resource(), plus cache and cacheKey. Returns stale data instantly, re-fetches in background.
import { cachedResource } from 'ngx-ziflux'
@Injectable()
export class OrderListStore {
readonly #api = inject(OrderApi)
readonly filters = signal<OrderFilters>({ status: 'all' })
readonly orders = cachedResource({
cache: this.#api.cache,
cacheKey: params => ['order', 'list', params.status],
params: () => this.filters(),
loader: ({ params }) => this.#api.getAll$(params),
})
}4 · Template
isInitialLoading() is true only when there's no cached data. Subsequent visits skip the spinner entirely.
@Component({
providers: [OrderListStore],
template: `
@if (store.orders.isInitialLoading()) {
<app-spinner />
} @else {
<app-order-list [orders]="store.orders.value()" />
}
`,
})
export class OrderListComponent {
readonly store = inject(OrderListStore)
}That's it. Navigate away, come back — data loads instantly from cache.
For read-only use cases, you can skip the Store layer entirely — see Factory pattern.
How ziflux compares #
You'll compare anyway — here's the honest positioning.
TanStack Query (Angular)
Full-featured, framework-agnostic. More concepts to learn (query keys, observers, query client). Great if you need advanced features like infinite queries or SSR hydration.
NgRx
State management + effects, much larger scope. Reducers, actions, selectors — powerful for complex global state, but heavy for just caching HTTP responses.
ziflux
SWR caching only. Signal-native. Zero learning curve if you know resource(). Fewer concepts, less API surface, more clarity.
npm install ngx-zifluxGitHub · MIT License · Zero dependencies
Guide #
Quick Start gave you the basics. Now: detail views, error handling, mutations, and optimistic updates.
Architecture(skip if you just want recipes ↓)
Component
view scope
Store
route scope
API Service (root scope)
DataCache
SWR · dedup · invalidation
Server
via loader
Domain pattern
A recommended structure for most features:
order.api.tsHTTP + cache
singleton
order-list.store.tscachedResource + mutations
route-scoped
order-list.component.tsinject(Store), read signals
view scope
Why the cache must be a singleton
Two things need different lifetimes, and that tension drives the architecture:
- Cache must be
providedIn: 'root'— it survives route navigations so SWR works across pages. - Reactive params (filters, IDs) are route-scoped — each route instance gets its own independent state.
You can't merge both lifetimes without losing one or the other. The 3-file pattern solves this by separating the cache host (API service, root) from the reactive state (Store, route-scoped). The API service is a natural choice — but DataCache works anywhere with an injection context. A dedicated OrderCache service works just as well.
cachedResource directly in a component if your use case is simple.Guidelines
- Components shouldn't inject an API service directly
- Keep HTTP logic in the API service, not the store
- The store shouldn't instantiate a
DataCache— it readsthis.#api.cache - Mutations invalidate the cache via
invalidateKeys— the store handles this, not the API service
Naming conventions
| Concept | Class name | File name |
|---|---|---|
| API service | OrderApi | order.api.ts |
| List store | OrderListStore | order-list.store.ts |
| Detail store | OrderDetailStore | order-detail.store.ts |
Recipes
Picks up where Quick Start left off — using the same API service and list store from there.
1. Fetch a single resource by ID
@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
@if (store.orders.isInitialLoading()) {
<app-spinner />
} @else {
<app-order-list [orders]="store.orders.value()" />
}When the server fails but stale data exists, show both:
@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 cache after a mutation
Replaces ~13 lines of boilerplate per mutation with a 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 you need to react to the result in TypeScript:
@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
Optimistic updates make the UI feel instant: update the screen before the server responds, then roll back if it fails.
Mutation lifecycle
onMutate(args)— Runs before the API call. Snapshot the current state, apply the optimistic change, and return the snapshot.mutationFn(args)— The actual API call.- Success:
onSuccessfires, theninvalidateKeysmarks cache entries stale socachedResourcerefetches from the server. - Error:
onErrorreceives the snapshot as its third argument (context). Use it to restore the UI.
readonly updateOrder = cachedMutation({
cache: this.#api.cache,
mutationFn: (args) => 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
},
})In the template:
@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
readonly isAnythingLoading = anyLoading(
this.orders.isLoading,
this.deleteOrder.isPending,
)isLoading is for cachedResource — true while fetching data (initial load or background revalidation). isPending is for cachedMutation — true while the mutation is in-flight. Both are Signal<boolean>, so anyLoading() combines them seamlessly.
How caching works #
Every cached entry goes through three phases. invalidate() marks entries stale — it never deletes them.
FRESH
STALE
EVICTED
Return cached data
No network request
Return cached + re-fetch
User sees data instantly, refresh in background
Fetch from server
Cache entry removed
staleTime elapsed — data may be outdatedexpireTime elapsed — entry evictedWhat the user sees
| Scenario | Cache | UI |
|---|---|---|
| First visit ever | miss | Spinner → data |
| Return visit (data < staleTime) | fresh | Data instantly, no fetch |
| Return visit (data > staleTime) | stale | Stale data instantly → silent refresh → fresh data |
| After mutation | stale | Data + silent refresh (cache invalidated by mutation) |
| Network error, had cache | stale | Stale data shown, no crash |
Cache keys
You delete an order. The list, the detail page, every filtered view — all need to refresh. Cache keys make this one line:
cache.invalidate(['order']) // ← one call, everything refreshesSee the Guide for full optimistic update and mutation examples.
When to cache
Cache
- GET — entity lists
- GET — entity details
- Data shared across multiple screens
- Predictable access patterns (tabs, navigation)
Don't cache
- POST / PUT / DELETE
- Search results with volatile params
- Real-time data (WebSocket, SSE)
- Large binaries
Alternative patterns #
The Guide shows the recommended 3-file pattern. Here's a leaner alternative for simpler use cases.
Factory pattern
A singleton service that owns HTTP + cache + factory methods, returning CachedResourceRef directly. The consumer provides reactive params, Angular manages lifecycle. No separate Store needed.
@Injectable({ providedIn: 'root' })
export class OrderApiCached {
readonly #http = inject(HttpClient)
readonly #cache = new DataCache()
getAll(params: () => OrderFilters | undefined) {
return cachedResource({
cache: this.#cache,
cacheKey: p => ['order', 'list', p.status],
params,
loader: ({ params }) => this.#http.get<Order[]>('/orders', { params: { ...params } }),
})
}
getById(id: () => string | null) {
return cachedResource({
cache: this.#cache,
cacheKey: p => ['order', 'details', p.id],
params: () => { const v = id(); return v ? { id: v } : undefined },
loader: ({ params }) => this.#http.get<Order>(`/orders/${params.id}`),
})
}
}Consumer
readonly #api = inject(OrderApiCached)
readonly filters = signal<OrderFilters>({ status: 'all' })
readonly orders = this.#api.getAll(() => this.filters())What this gives you
- HTTP + cache + keys in one place (real cohesion)
- Consumer doesn't wire
cache:orcacheKey: CachedResourceRefstill returned — all SWR signals preserved- Lifecycle managed by Angular's injection context
When to use which
3-file pattern (API + Store + Component)
- Mutations + optimistic updates
- Derived state, complex UI logic
- Multiple resources coordinated
Factory pattern (ApiCached + Component)
- Read-only data fetching
- Simple list / detail views
- Fewer files, less boilerplate
Both patterns use the same library API. cachedResource() works identically in both cases — this is purely an organizational choice.
Testing #
DataCache and cachedResource require an Angular injection context. Use TestBed.
Testing a store
describe('OrderListStore', () => {
let store: OrderListStore
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideZiflux(),
provideHttpClient(),
provideHttpClientTesting(),
OrderApi,
OrderListStore,
],
})
store = TestBed.inject(OrderListStore)
})
it('loads orders', async () => {
const httpTesting = TestBed.inject(HttpTestingController)
// Flush the HTTP request
httpTesting.expectOne('/orders').flush([{ id: '1', status: 'pending' }])
await new Promise(r => setTimeout(r, 0)); TestBed.tick()
expect(store.orders.value()).toHaveLength(1)
})
})Testing a standalone DataCache
Use runInInjectionContext when you need a bare cache without the full store setup.
let cache: DataCache
beforeEach(() => {
TestBed.configureTestingModule({})
cache = TestBed.runInInjectionContext(() => new DataCache())
})
it('stores and retrieves data', () => {
cache.set(['key'], 'value')
expect(cache.get<string>(['key'])?.data).toBe('value')
})API reference #
All runtime exports — signatures and usage examples.
Own one per domain, in your API service (singleton).
Signature
class DataCache {
readonly name: string // devtools label (auto-generated if omitted)
readonly version: Signal<number> // auto-increments on invalidate()
readonly staleTime: number // resolved config value
readonly expireTime: number // resolved config value
constructor(options?: {
name?: string
staleTime?: number
expireTime?: number
cleanupInterval?: number // ms between auto-eviction sweeps
maxEntries?: number // LRU cap, oldest evicted on write
})
get<T>(key: string[], options?: { staleTime?: number; expireTime?: number }): { data: T; fresh: boolean } | null
set<T>(key: string[], data: T): void
invalidate(prefix: string[]): void // marks stale + bumps version
wrap<T>(key: string[], obs$: Observable<T>): Observable<T>
deduplicate<T>(key: string[], fn: () => Promise<T>): Promise<T>
prefetch<T>(key: string[], fn: () => Promise<T>): Promise<void>
clear(): void
cleanup(): number // evict expired entries, return count
inspect(): CacheInspection<unknown> // point-in-time snapshot for devtools
}Usage
readonly cache = new DataCache({ name: 'orders', maxEntries: 100 })
// Read from cache
const entry = this.cache.get(['order', 'details', '42'])
if (entry?.fresh) return entry.data
// Invalidate all "order" entries
this.cache.invalidate(['order']) // prefix matchGotchas #
Common pitfalls and how to avoid them.
invalidate([]) is a no-op
An empty prefix matches nothing. Use cache.clear() to wipe everything.
// This does nothing — empty prefix matches nothing
cache.invalidate([])// Use clear() to wipe the entire cache
cache.clear()invalidate() is prefix-based, not exact-match
A prefix matches all keys that start with it — including nested sub-keys.
// invalidate(['order', 'details', '42']) also matches:
// ['order', 'details', '42']
// ['order', 'details', '42', 'comments']
// ['order', 'details', '42', 'attachments']
//
// It does NOT match:
// ['order', 'details', '43']
// ['order', 'list']ref.set() / ref.update() write to the cache
They update both the Angular resource and the DataCache. Optimistic values survive version bumps from unrelated invalidations. Call invalidate() to trigger a fresh server fetch.
// set() and update() write to both the Angular resource AND the DataCache.
// Optimistic values survive cache version bumps from unrelated invalidations.
ref.set(newValue)
ref.update(prev => ({ ...prev, name: 'updated' }))
// To trigger a fresh server fetch after an optimistic update:
cache.invalidate(['order', 'details', '42'])Cache keys are untyped at the boundary
DataCache stores unknown internally. Type correctness depends on consistent key→type pairings in your code.
// Nothing prevents this — both compile fine
cache.set(['user', '1'], { name: 'Alice' }) // User
const entry = cache.get<Order[]>(['user', '1']) // reads as Order[]
// Convention: one key prefix per type, enforced in your API serviceAI skills
Give your AI coding agent deep ziflux expertise — works with Claude Code, Cursor, Windsurf, and any skills.sh-compatible tool.
npx skills add neogenz/zifluxImplementation patterns
Domain architecture, cachedResource setup, mutations, optimistic updates, polling, and retry.
Code review checklist
Architecture rules, cache key design, signal usage, and common anti-patterns to catch.
Debugging guide
Stale data issues, NG0203 errors, idle resources, duplicate requests, and devtools usage.
Testing patterns
TestBed setup, store testing, DataCache testing, mutation testing, and fake timers.