Skip to content

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

SWR caching for Angular resource()

Stale-while-revalidate for resource(). Instant navigations, silent background refreshes.

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. If you know resource() and signals, you already know ziflux. 3 core functions, zero dependencies, ~2KB.

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.

For read-only use cases, you can skip the Store layer entirely — see Factory pattern.

When ziflux is the wrong tool #

SWR caching is narrow on purpose. If your problem looks like one of these, reach for something else.

Real-time data (WebSocket / SSE)

Why not: SWR assumes data ages on a wall clock. Push streams update on events, not on staleness.

Use instead: Subscribe to the stream directly. Cache the snapshot if you need offline display.

SSR transfer state

Why not: ziflux is in-memory and does not serialize across the server/client boundary.

Use instead: Use Angular's TransferState for SSR hydration; layer ziflux on top after rehydration.

Persistence across browser reloads

Why not: The cache lives in process memory and is gone on refresh.

Use instead: Persist explicitly with localStorage / IndexedDB; ziflux handles the in-memory layer above.

Volatile search-as-you-type

Why not: New params on every keystroke means new cache keys. LRU thrashes; the cache becomes overhead.

Use instead: Debounce the input; cache only the stable result keys you actually want to revisit.

Angular before v21

Why not: ziflux is built on Angular's `resource()` API.

Use instead: Stay on whatever cache pattern you have today; revisit when you upgrade.

Global state orchestration

Why not: ziflux caches data; it does not coordinate workflows, side-effects, or cross-feature actions.

Use instead: NgRx, NgRx SignalStore, or a state machine. Pair it with ziflux for the data layer.

Guide #

Quick Start gave you the basics. Now: detail views, error handling, mutations, and optimistic updates.

Architecture(skip if you just want recipes )
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

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

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. Display cached data in 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. Invalidate cache after a mutation

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)"
  [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:

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

Optimistic updates make the UI feel instant: update the screen before the server responds, then roll back if it fails.

Mutation lifecycle

  1. onMutate(args) — Runs before the API call. Snapshot the current state, apply the optimistic change, and return the snapshot.
  2. mutationFn(args) — The actual API call.
  3. Success: onSuccess fires, then invalidateKeys marks cache entries stale so cachedResource refetches from the server.
  4. Error: onError receives the snapshot as its third argument (context). Use it to restore the UI.
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']],

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

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

5. Combine loading states

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

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

When ziflux pays off #

Three patterns where SWR caching on resource() earns its keep.

Admin dashboard with tabs

PainUser flips between Orders / Invoices / Customers tabs. Spinner on every switch, even when the data hasn't changed.

FixTabs read from a shared cache. First visit fetches; every return is instant. Background refresh kicks in only when data is stale.

// Each tab is a route. Each route's store reads from a shared cache.
@Injectable() export class OrdersStore {
  readonly orders = cachedResource({
    cache: this.api.cache,
    cacheKey: ['orders', 'list'],
    loader: () => this.api.getOrders$(),
  })
}

// Tab switch → store destroyed → cache survives in the API service.
// Switch back → cachedResource reads cache → instant. Background refetch
// kicks off if the entry is stale.

E-commerce list → detail → back

PainUser filters a list, opens a product, edits something, navigates back. List reloads from scratch. Filter state survives, network roundtrip doesn't.

FixHierarchical keys (`['product', 'list', filters]` and `['product', 'details', id]`). One `invalidate(['product'])` after the mutation refreshes both views.

// List + detail share a key prefix. One mutation invalidates both.
cacheKey: params => ['product', 'list', params.status, params.sortBy]
cacheKey: params => ['product', 'details', params.id]

// After a mutation:
cache.invalidate(['product'])  // marks BOTH list and details stale
// Background refetch on the visible view, untouched cache for the rest.

Multi-step form with dependent data

PainStep 2 depends on Step 1. User backs up, changes Step 1, returns to Step 2. The dependent fetch fires every single time.

Fix`params` returns `undefined` until ready (idle state). Once a value is picked, fetch fires once and caches per-key. Re-picking an earlier value is instant.

// Step 2's params depend on Step 1's selection.
readonly categoryId = signal<string | null>(null)

readonly products = cachedResource({
  cache: this.api.cache,
  cacheKey: p => ['product', 'by-category', p.categoryId],
  params: () => {
    const id = this.categoryId()
    return id ? { categoryId: id } : undefined  // idle until set
  },
  loader: ({ params }) => this.api.getByCategory$(params.categoryId),
})

// User backs to Step 1, picks a different category → resource refetches.
// Re-picks the original category → instant from cache.

How ziflux compares #

You'll compare anyway — here's the honest positioning.

TanStack Query (Angular)

Full data-fetching framework. Infinite queries, SSR hydration, persistence, cross-framework. Pick this if your data layer needs the whole toolbox.

NgRx

Full state container. Reducers, effects, selectors, time-travel. Pick this when caching is a side-effect of complex global state, not the goal.

ziflux

Caches resource(). That's the entire scope. Signal-native, no new mental model. Pick this when SWR on top of Angular's built-in primitives is what you need — nothing more.

Mutation lifecycle (onMutate → mutationFn → onSuccess → invalidateKeys) is modeled on React Query — proven shape, signal-native execution.

npm install ngx-ziflux

GitHub · MIT License · Zero dependencies

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() 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 service

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.