Skip to content

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

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

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

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.