Skip to content

RFC: withEntityResources - Resources & Entities together #236

@rainerhahnekamp

Description

@rainerhahnekamp

Add a new feature called withEntityResources that integrates withEntities directly into the resource handling pattern.

Background

The ngrx-toolkit currently supports withResources to handle asynchronous read operations. In addition, withEntities (@ngrx/signals/entities) provides a structured way to manage collections of entities.

Many use cases involve resources that represent entity collections. Currently, developers need to manually wire a resource result into an entity state, which adds boilerplate and redundancy. We propose introducing withEntityResource to cover this scenario.

The Challenge: State Structure Mismatch

The main challenge we face is a fundamental mismatch between how resources and entities manage state:

  • withResource expects a state property called value to store the resource result
  • withEntities provides entityMap and ids properties for entity management

Therefore, both would require manual synchronization.

API Examples

Basic usage

export interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

export const TodoStore = signalStore(
  withEntityResource((store) =>
    httpResource(() => '/todo', {
      defaultValue: [],
      parse: toTodos
    }),
  ),
  withMutations((store) => ({
    addTodo: httpMutation({
      request: (todo: Todo) => ({
        url: 'todo',
        method: 'POST',
        body: todo,
      }),
      parse: toTodos,
      onSuccess((todo: Todo) => {
        patchState(store, addEntity(todo))
      })
    }),
    // ... other mutations
  }))
);

const todoStore = inject(TodoStore);

todoStore satisfies Resource<Todo[]>; // same behavior as in `withResource`
todoStore.value satisfies Signal<Todo[]>;
todoStore.entities satisfies Signal<Todo[]>;

todoStore.addTodo({ id: 2, title: 'Car Wash', completed: false });

In this version, withEntityResource works with just one entity. The type of the entity is inferred from the httpResource.

In case the httpResource returns ResourceRef<Todo[] | undefined>, the undefined is ignored.

Named entities

signalStore(
  withEntityResources(({ activeId }) => ({
    todos: httpResource(() => '/todo', {
      defaultValue: [],
      parse: toTodos
    }),
    projects: httpResponse(() => `/project}`), {
        parse: toProjects
    }
  })),
);

In case withEntityResources gets passed a dictionary, the keys are used for the collection name and there will be no default entity, i.e. entities property.

Same goes the resources. They are also generated as named resources like in withResource.

Design Overview

  • Composed Internally: withEntityResource calls withEntities internally.
  • Supports Named Entities: Fully supports named entities, just like withEntities and withResource.
  • Linked Signals for Sync: The internal entityMap and ids are created as linkedSignals based on the resource’s value. This ensures that whenever the resource reloads or updates, the entities are synchronously updated without additional patching logic.
  • Computed value (Read-only): Unlike withResource, the value exposed by withEntityResource is not writable and implemented as a computed signal that reflects the entities (NOT value of the resource). The reason is that users will be able to modify the entities state but not the resource. This value is not stored in the state and cannot be patched or manually updated. Instead, the only writable source of truth remains the internal resource, which handles updates exclusively. This avoids conflicts where one part of the code writes to value and another to entityMap or ids, potentially causing unintentional overrides.
  • Type-Safe Contract: The value returned by the resource and the expected entity shape are type-checked for compatibility by design.
  • Secondary Entry Point: To avoid unnecessary coupling, withEntityResource will be published as a secondary entry point due to its dependency on withEntities.

Alternatives Considered

It could be possible that we provide a "glueing feature", which would glue together an existing resource with an entity. For example:

export const TodoStore = signalStore(
  withEntities<Todo>(),
  withEntityResources((store) =>
    httpResource(() => '/todo', {
      defaultValue: [],
      parse: todo,
    }),
  ),
  withEntityResource(),
);

In this case withEntityResource would internally check for an existing resource and entities, verify that they are of the same type and internally replace the value in the state with a computed value and put it into props.

Since this is considered as a very hackish approach (how to revert state properties once they have been already available for other feature?), we might not want to continue in that direction.

Implementation Details

Internal Design

The feature is essentially a composition of withResource and withEntities:

// PSEUDOCODE!!!

export function withEntityResource<T>(
  options: EntityOptions & {
    resource: ResourceOptions<T[]>;
  },
) {
  return signalStoreFeature(
    withEntities<T>(options), // internal call
    withResource<T[]>(options.resource, {
      onValue: (store, value) => {
        store.setAllEntities(value); // keeps entities in sync
      },
    }),
    withComputed((store) => ({
      value: () => store.entities(),
    })),
  );
}

We cannot use withResource directly because the logic (see #desing-overview) doesn't fully match and will probably have to find a way to duplicate code or something else.

The same is true for withEntities. They need to be generated as linked Signals and not in the way how withEntities does it.

Also for named entities, there is much more type inference included.

Implementation Steps

This is a working draft that will evolve based on implementation challenges and feedback:

  1. Implement a prototype and provide a working Stackblitz example.
  2. Give interested people the time to give it a try.
  3. Decide to move on with final implementation or do another round, based on feedback.

Deliverables

It is possible to split the deliverables up into multiple PRs and also distribute the workload to several people.

  • Tests
  • Basic Implementation
  • Demo
  • JSDoc
  • E2E Tests for Demo
  • Documentation (Docusaurus)

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions