Skip to content

RFC(@ngrx/signals): Resource Integration Part 1 - Introduce Extend Resource APIs #5126

@markostanimirovic

Description

@markostanimirovic

Which @ngrx/* package(s) are relevant/related to the feature request?

signals

Information

Introduce a set of utilities for extending Angular Resource behavior with composable, reusable extensions.

Motivation

The current behavior of Angular Resource APIs is opinionated and leaves limited room for customization. Two common real-world requirements that are not configurable at the resource level:

  • Value on loading — when a resource reloads, value() resets to undefined until the new data arrives. There is no built-in way to preserve the previous value during a reload.
  • Value on error — when a resource enters the error state, value() throws. There is no built-in way to return a fallback or the previous value instead.

Angular recently introduced Resource Snapshots, which allow composing resources and can help with the "preserve value on loading" case. However, the error behavior remains unaddressable, and resourceFromSnapshot always returns the base Resource<T> type, meaning any additional APIs from more specific resource types (e.g. WritableResource) are lost in the process.

Overriding the default error behavior is also a prerequisite for introducing Resource integration for SignalStore. With the default behavior, once a resource enters the error state, any subsequent patchState call will throw because it reads value() internally — breaking the store.

Ideally, these behaviors would be configurable directly on the resource. Since they are not supported natively, this set of utilities offers what feels like the most ergonomic way to achieve this today: a thin extension layer that wraps any resource and patches only the behavior you want to change, while fully preserving the original resource type.

Prototype

Examples and source code of resource extensions are available here: https://github.com/markostanimirovic/extend-resource-prototype

Quick Start

import { extendResource, withPreviousValueOnLoading, withValueOnError } from 'extend-resource';

@Component({
  /* ... */
})
export class TodoList {
  readonly todosResource = extendResource({
    resource: httpResource(() => `https://dummyjson.com/todos`),
    extensions: [withPreviousValueOnLoading(), withValueOnError(undefined)],
  });
}

API

extendResource

Applies extensions to a resource and returns it with its original type preserved.

const todosResource = extendResource({
  resource: httpResource(() => `https://dummyjson.com/todos`),
  extensions: [withPreviousValueOnLoading(), withValueOnError(undefined)],
});

provideResourceExtensions

Registers extensions for a given injector scope (application, route, component). Any resource wrapped with extendResource inside that scope will automatically have these extensions applied.

// main.ts
bootstrapApplication(App, {
  providers: [provideResourceExtensions(withValueOnError(undefined))],
});

With global extensions in place, calling extendResource with just a resource is enough:

@Component({
  /* ... */
})
export class TodoList {
  // Global extensions are applied automatically
  readonly todosResource = extendResource(httpResource(() => `/api/todos`));
}

Additional per-resource extensions are appended after the global ones:

@Component({
  /* ... */
})
export class TodoList {
  // Global extensions run first, then withPreviousValueOnLoading
  readonly todosResource = extendResource({
    resource: httpResource(() => `/api/todos`),
    extensions: [withPreviousValueOnLoading()],
  });
}

provideResourceExtensions is scope-aware and composes with parent injector extensions, so you can layer defaults at the app level and add more specific behavior at the route or component level without losing the parent configuration.

Extensions

Extensions are plain functions with the signature (resource: R) => void. They receive the resource and patch its behavior in place. The built-in extensions all follow the same pattern and can be freely combined.

withPreviousValueOnLoading()

Keeps the last resolved value while the resource is reloading, instead of resetting to undefined.

const todosResource = extendResource({
  resource: httpResource(() => `/api/todos?page=${this.page()}`),
  extensions: [withPreviousValueOnLoading()],
});

// value() returns the previous page's data while the next page is loading,
// rather than undefined.

withValueOnLoading(value)

Returns a specific fallback value while the resource is loading.

const todosResource = extendResource({
  resource: httpResource(() => `/api/todos`),
  extensions: [withValueOnLoading([])],
});

// value() returns [] on initial load and on every subsequent reload.

withPreviousValueOnError()

Returns the last successfully resolved value when the resource enters an error state, instead of throwing.

const todosResource = extendResource({
  resource: httpResource(() => `/api/todos`),
  extensions: [withPreviousValueOnError()],
});

// If the request fails, value() returns whatever was successfully loaded before,
// rather than throwing an error.

withValueOnError(value)

Returns a specific fallback value when the resource enters an error state, instead of throwing.

const todosResource = extendResource({
  resource: httpResource(() => `/api/todos`),
  extensions: [withValueOnError([])],
});

// If the request fails, value() returns [] instead of throwing.

I would be willing to submit a PR to fix this issue

  • Yes
  • No

Metadata

Metadata

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions