Skip to content

Angular 21+ · Zero dependencies · Signal-native · Tree-shakeable

SWR caching for Angular resource()

Instant navigations, background refreshes, zero spinners.

npm install ngx-ziflux
order-list.store.ts
const 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-ziflux
app.config.ts
import { 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.

order.api.ts
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.

order-list.store.ts
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.

order-list.component.ts
@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

ziflux

Component

view scope

Store

route scope

API Service (root scope)

DataCache

SWR · dedup · invalidation

Server

via loader

cachedResource()cachedMutation()
Signals flow back to Component

Domain Pattern

A recommended structure for most features:

1order.api.ts

HTTP + cache

singleton

2order-list.store.ts

cachedResource + mutations

route-scoped

3order-list.component.ts

inject(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

  1. Components shouldn't inject an API service directly
  2. Keep HTTP logic in the API service, not the store
  3. The store shouldn't instantiate a DataCache — it reads this.#api.cache
  4. Mutations invalidate the cache via invalidateKeys — the store handles this, not the API service

Naming Conventions

Recommended naming conventions for API services, list stores, and detail stores
ConceptClass nameFile name
API serviceOrderApiorder.api.ts
List storeOrderListStoreorder-list.store.ts
Detail storeOrderDetailStoreorder-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

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)
  }
}

2. Templates

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:

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 />
  }
}

3. Mutations with cachedMutation()

Replaces ~13 lines of boilerplate per mutation with a 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)">Delete</button>
@if (store.deleteOrder.isPending()) { <app-spinner /> }

4. Optimistic Updates + Rollback

Use onMutate to apply optimistic changes, return rollback context, revert on error.

order-list.store.ts
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

order-list.store.ts
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

Data written to cache
staleTime elapsed — data may be outdated
expireTime elapsed — entry evicted

What the user sees

Cache state and corresponding UI behavior for each navigation scenario
ScenarioCacheUI
First visit evermissSpinner → data
Return visit (data < staleTime)freshData instantly, no fetch
Return visit (data > staleTime)staleStale data instantly → silent refresh → fresh data
After mutationstaleData + silent refresh (cache invalidated by mutation)
Network error, had cachestaleStale 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:

['order']← invalidate here, everything below becomes stale
['order', 'list']all orders page
['order', 'list', 'pending']filtered view
['order', 'details', '42']detail page
cache.invalidate(['order'])   // ← one call, everything refreshes

See 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.

order.api-cached.ts
@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

order-list.component.ts
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: or cacheKey:
  • CachedResourceRef still 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

order-list.store.spec.ts
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.

data-cache.spec.ts
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 match

Gotchas #

Common pitfalls and how to avoid them.

!

invalidate([]) is a no-op

An empty prefix matches nothing. Use cache.clear() to wipe everything.

No effect
// This does nothing — empty prefix matches nothing
cache.invalidate([])
Correct
// 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
// Local only — the cache doesn't know about this
ref.set(newValue)
ref.update(prev => ({ ...prev, name: 'updated' }))
Triggers fetch
// 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 service

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-ziflux

GitHub · 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/ziflux

Implementation 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.