-
Notifications
You must be signed in to change notification settings - Fork 44
Description
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 calledvalue
to store the resource resultwithEntities
providesentityMap
andids
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
callswithEntities
internally. - Supports Named Entities: Fully supports named entities, just like
withEntities
andwithResource
. - Linked Signals for Sync: The internal
entityMap
andids
are created as linkedSignals based on the resource’svalue
. This ensures that whenever the resource reloads or updates, the entities are synchronously updated without additional patching logic. - Computed
value
(Read-only): UnlikewithResource
, thevalue
exposed bywithEntityResource
is not writable and implemented as a computed signal that reflects theentities
(NOTvalue
of the resource). The reason is that users will be able to modify the entities state but not the resource. Thisvalue
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 tovalue
and another toentityMap
orids
, 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 onwithEntities
.
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:
- Implement a prototype and provide a working Stackblitz example.
- Give interested people the time to give it a try.
- 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)