Angular 21+ · Zero dependencies · Signal-native · Tree-shakeable
SWR caching for Angular resource()
Instant navigations, background refreshes, zero spinners.
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.
Guide #
Quick Start gave you the basics. Now: detail views, error handling, mutations, and optimistic updates.
Architecture
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.
The library works without a store layer — use 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 |
Usage
Picks up where Quick Start left off — using the same API service and list store from there.
1. Store — Detail 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. 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. Mutations with cachedMutation()
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)">Delete</button>
@if (store.deleteOrder.isPending()) { <app-spinner /> }4. Optimistic Updates + Rollback
Use onMutate to apply optimistic changes, return rollback context, revert on error.
readonly updateOrder = cachedMutation({
cache: this.#api.cache,
mutationFn: (args) => this.#api.update$(args.id, args.data),
invalidateKeys: (args) => [['order', 'details', args.id], ['order', 'list']],
onMutate: (args) => {
const prev = this.orders.value()
this.orders.update(list =>
list?.map(o => (o.id === args.id ? { ...o, ...args.data } : o)),
)
return prev // rollback context
},
onError: (_err, _args, context) => {
if (context) this.orders.set(context)
},
})5. Aggregate Loading State
readonly isAnythingLoading = anyLoading(
this.orders.isLoading,
this.deleteOrder.isPending,
)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() are local-only
They update the component's view but do NOT write to the cache. Call invalidate() to trigger a fresh server fetch.
// Local only — the cache doesn't know about this
ref.set(newValue)
ref.update(prev => ({ ...prev, name: 'updated' }))// To trigger a fresh server fetch, invalidate the cache
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 serviceHow 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
AI 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.