https://github.com/zxnc/zx-angular-lazy-resource
https://github.com/zxnc/zx-angular-lazy-resource
Last synced: 24 days ago
JSON representation
- Host: GitHub
- URL: https://github.com/zxnc/zx-angular-lazy-resource
- Owner: zxnc
- License: mit
- Created: 2026-06-24T06:56:45.000Z (about 1 month ago)
- Default Branch: main
- Last Pushed: 2026-06-24T09:55:17.000Z (about 1 month ago)
- Last Synced: 2026-06-24T10:35:25.327Z (about 1 month ago)
- Language: TypeScript
- Size: 31.3 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
- awesome-angular - zx-angular-lazy-resource - Lazy helpers for Angular's signal-based `resource()` — defer loading until first access, and await the first settled value as a promise. (Architecture and Advanced Topics / HTTP)
- fucking-awesome-angular - zx-angular-lazy-resource - Lazy helpers for Angular's signal-based `resource()` — defer loading until first access, and await the first settled value as a promise. (Architecture and Advanced Topics / HTTP)
README
# zx-angular-lazy-resource
> Lazy helpers for Angular's signal-based `resource()` — defer loading until first access, and `await` the first settled value as a Promise.
[](https://www.npmjs.com/package/zx-angular-lazy-resource)
[](https://github.com/zxnc/zx-angular-lazy-resource/actions/workflows/ci.yml)
[](./LICENSE)
Two tiny, dependency-light utilities built on top of Angular's `resource()`:
- **`lazyResource()`** — a `resource()` that does **not** fire its request on app
startup. The loader runs only the **first time the resource is accessed**.
- **`takeLazyResource()`** — `await` a resource and get its **first real value**
(never the empty/default one), as a `Promise`.
---
## The problem
Angular's `resource()` is great, but its loader runs **as soon as the resource is
created**. If you keep a bunch of shared resources in a service…
```ts
@Service()
export class GlobalStore {
private api = inject(Api);
// ❌ Every one of these fires an HTTP request the moment the app boots,
// even if the user never opens the screen that needs them.
readonly brands = resource({ loader: () => this.api.getBrands(), defaultValue: [] });
readonly currencies = resource({ loader: () => this.api.getCurrencies(), defaultValue: [] });
readonly vats = resource({ loader: () => this.api.getVats(), defaultValue: [] });
}
```
…you end up flooding your backend with requests at startup for data you may not
need yet.
`lazyResource()` fixes this: each resource waits until it is actually read.
```ts
@Service()
export class GlobalStore {
private api = inject(Api);
// ✅ No request until something reads `.value()` (template, computed, await, ...).
readonly brands = lazyResource(() => this.api.getBrands(), []);
readonly currencies = lazyResource(() => this.api.getCurrencies(), []);
readonly vats = lazyResource(() => this.api.getVats(), []);
}
```
---
## Installation
```bash
npm install zx-angular-lazy-resource
```
**Peer dependencies:** `@angular/core` (>= 22) and `rxjs` (>= 7) — both already
present in any Angular app.
---
## Quick start
### 1. Declare lazy resources
```ts
import { Service, inject } from '@angular/core';
import { lazyResource } from 'zx-angular-lazy-resource';
@Service()
export class CatalogStore {
private api = inject(CatalogApi);
readonly brands = lazyResource(() => this.api.getBrands(), []);
}
```
### 2a. Use it synchronously (template / computed)
Reading `.value()` works exactly like a normal resource — and it transparently
triggers the load the first time:
```ts
@Component({
template: `
@if (store.brands.isLoading()) {
Loading…
} @else {
@for (brand of store.brands.value(); track brand.id) {
{{ brand.name }}
}
}
`,
})
export class BrandsComponent {
protected store = inject(CatalogStore);
}
```
### 2b. Use it asynchronously (`await` the first real value)
`value()` is synchronous, so on the very first read it may still be the default
(`[]`). When you need to be sure you have the server response, `await` it:
```ts
async function loadActiveBrands(store: CatalogStore) {
const brands = await takeLazyResource(store.brands);
return brands.filter((b) => b.active); // never runs on an empty default
}
```
A convenient pattern is to expose an `...Async` helper next to each resource:
```ts
@Service()
export class CatalogStore {
private api = inject(CatalogApi);
readonly brands = lazyResource(() => this.api.getBrands(), []);
readonly brandsAsync = () => takeLazyResource(this.brands);
}
// somewhere else:
const brands = await store.brandsAsync();
```
---
## API
### `lazyResource(loader, defaultValue, optionsOrInjector?)`
| Param | Type | Description |
| ------------------- | --------------------------------- | ------------------------------------------------------------------------------------------ |
| `loader` | `() => Promise` | Async function that fetches the data. Runs once, on first access. |
| `defaultValue` | `T` | Value exposed by `value()` before the loader resolves. |
| `optionsOrInjector` | `LazyResourceOptions \| Injector` | Optional. An options object (see below) or, for backwards compatibility, a bare `Injector`. |
`LazyResourceOptions`:
| Property | Type | Description |
| ---------- | --------------------- | ------------------------------------------------------------------------------------------------- |
| `id` | `string` (optional) | Enables Angular's **SSR `TransferState` caching** for this resource (see below). |
| `injector` | `Injector` (optional) | Only needed when called **outside** an injection context. Defaults to `inject(Injector)`. |
**Returns** `ResourceRef` — a normal resource ref (`value()`, `status()`,
`isLoading()`, `hasValue()`, `error()`, `reload()`, …). The only difference is
that the loader is deferred until the first property access.
#### SSR caching with `TransferState`
When an app renders on the server, the resource loader runs once to produce the
initial HTML; during hydration the browser would normally run the same loader
again. Provide an `id` to reuse the server result: Angular stores the resolved
value in `TransferState` on the server and uses it on the client to initialize
the resource in a `'resolved'` state.
```ts
@Service()
export class UserStore {
private api = inject(UserApi);
// The value resolved on the server is reused on the client — no second request.
readonly user = lazyResource(() => this.api.getUser(), null, { id: 'current-user' });
}
```
The `id` must be **unique within your application** and **identical on the
server and the client** so Angular can match the cached entry.
> ⚠️ Because the cached value is serialized into the page's HTML, avoid using an
> `id` for resources that load **user-specific** data when the rendered HTML can
> be cached or shared between users.
The third argument is still backwards compatible with a bare `Injector`:
```ts
lazyResource(() => api.getBrands(), [], injector); // injector only
lazyResource(() => api.getBrands(), [], { injector }); // injector via options
lazyResource(() => api.getBrands(), [], { id, injector }); // id + injector
```
### `takeLazyResource(ref, optionsOrInjector?)`
| Param | Type | Description |
| ------------------- | ------------------------------------- | -------------------------------------------------------------------------------------------- |
| `ref` | `ResourceRef` | The resource to await. Works with `lazyResource` and with a plain `resource()`. |
| `optionsOrInjector` | `TakeLazyResourceOptions \| Injector` | Optional. An options object (see below) or, for backwards compatibility, a bare `Injector`. |
`TakeLazyResourceOptions`:
| Property | Type | Description |
| ---------- | --------------------- | -------------------------------------------------------------------------------------------- |
| `reload` | `boolean` (optional) | When `true`, force a fresh fetch (`ref.reload()`) and resolve with the reloaded value. |
| `injector` | `Injector` (optional) | Reuses the injector captured by `lazyResource`; otherwise falls back to `inject(Injector)`. |
**Returns** `Promise` that:
- **resolves** with the first `'resolved'` (or `'local'`) value, and
- **rejects** with the resource's error if the loader fails.
#### Fresh data on every call
By default `takeLazyResource` resolves with the resource's cached value once it
has loaded. Pass `{ reload: true }` to force a refetch instead: it calls
`ref.reload()`, skips the stale cached value, and resolves with the freshly
loaded one.
```ts
// Reuses the cached value (loads once, then cached):
const brands = await takeLazyResource(store.brands);
// Always refetches and resolves with up-to-date data:
const fresh = await takeLazyResource(store.brands, { reload: true });
```
If a reload turns out to be unnecessary or unsupported (e.g. a load is already
in flight), the first settled value is used instead, so the call never hangs.
> 💡 `takeLazyResource` can be called from anywhere — event handlers, `async`
> methods, etc. — because `lazyResource` captures the injection context for you.
> For a plain (non-lazy) `resource()` called outside an injection context, pass
> the `injector` (via `{ injector }`) explicitly.
---
## How it works
`resource()` documents that **if the `params` computation returns `undefined`,
the loader does not run and the resource stays `'idle'`**.
`lazyResource` gates `params` behind an `enabled` signal:
```ts
resource({
params: () => (enabled() ? true : undefined), // undefined => loader never runs
loader,
defaultValue,
});
```
The returned resource is wrapped in a `Proxy`. The first time any property is
read, the proxy flips `enabled` to `true` (inside `untracked`, so it stays
side-effect-safe), which makes `params` produce a value and the loader runs
exactly once. Because the wrapper is a transparent `Proxy`, existing call sites
(`.value()`, `.status()`, …) keep working unchanged.
`takeLazyResource` listens to the resource's `status` signal (via
`toObservable`) and resolves the promise when it first settles. With
`{ reload: true }` it first calls `ref.reload()`, then waits for the resource to
enter a `'loading'`/`'reloading'` state and resolve again — skipping the stale
cached value so you get fresh data.
---
## Notes & caveats
- **One request, then cached.** Once loaded, the value is cached by the
resource. Call `ref.reload()` to fetch again.
- **`takeLazyResource` resolves on the first settled state.** If a `reload()` is
in progress, `value()` may still hold the previous value (Angular's normal
`'reloading'` behaviour). Use `{ reload: true }` to force a fresh fetch and
resolve with the reloaded value.
- **SSR:** loading is access-driven; if you render on the server, accessing the
resource during rendering triggers the load there too. Pass an `id` to cache
the server-resolved value via `TransferState` and skip the loader on the
client during hydration.
- **Reading is what triggers loading.** A resource that nothing ever reads will
never fire its request — which is exactly the point.
---
## Compatibility
| Package | Version |
| -------------- | -------- |
| `@angular/core`| >= 22 |
| `rxjs` | >= 7 |
`resource()` is stable as of Angular 22 (available as experimental in earlier
versions). This package targets the stable API, so Angular 22 is the minimum
supported version.
---
## Contributing
Issues and PRs are welcome. To build locally:
```bash
npm install
npm run build # outputs to ./dist
```
---
## License
[MIT](./LICENSE) © zxnc