)));
+ }
+ default:
+ return next(req);
+ }
+};
diff --git a/apps/demo/src/app/todo-entity-resource/todo-entity-resource.component.html b/apps/demo/src/app/todo-entity-resource/todo-entity-resource.component.html
new file mode 100644
index 00000000..aad6ccc2
--- /dev/null
+++ b/apps/demo/src/app/todo-entity-resource/todo-entity-resource.component.html
@@ -0,0 +1,60 @@
+
+
+ Filter
+
+
+
+
+ New todo
+
+
+
+
+
+
+
+
+
+
+ {{ row.completed ? 'check_box' : 'check_box_outline_blank' }}
+
+ delete
+
+
+
+
+ Title
+ {{ element.title }}
+
+
+
+
+
diff --git a/apps/demo/src/app/todo-entity-resource/todo-entity-resource.component.ts b/apps/demo/src/app/todo-entity-resource/todo-entity-resource.component.ts
new file mode 100644
index 00000000..fbeba5b3
--- /dev/null
+++ b/apps/demo/src/app/todo-entity-resource/todo-entity-resource.component.ts
@@ -0,0 +1,55 @@
+import { CommonModule } from '@angular/common';
+import { Component, computed, effect, inject } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { MatIcon } from '@angular/material/icon';
+import { MatInputModule } from '@angular/material/input';
+import { MatListModule } from '@angular/material/list';
+import { MatTableDataSource, MatTableModule } from '@angular/material/table';
+import { TodoEntityResourceStore } from './todo-entity-resource.store';
+
+@Component({
+ selector: 'demo-todo-entity-resource',
+ standalone: true,
+ imports: [
+ CommonModule,
+ FormsModule,
+ MatIcon,
+ MatInputModule,
+ MatListModule,
+ MatTableModule,
+ ],
+ templateUrl: './todo-entity-resource.component.html',
+ styles: [],
+})
+export class TodoEntityResourceComponent {
+ protected readonly store = inject(TodoEntityResourceStore);
+ protected newTitle = '';
+ protected readonly dataSource = new MatTableDataSource<{
+ id: number;
+ title: string;
+ completed: boolean;
+ }>([]);
+ protected readonly filtered = computed(() =>
+ this.store.entities().filter((t) =>
+ (this.store.filter() || '')
+ .toLowerCase()
+ .split(/\s+/)
+ .filter((s) => s.length > 0)
+ .every((s) => t.title.toLowerCase().includes(s)),
+ ),
+ );
+ constructor() {
+ effect(() => {
+ this.dataSource.data = this.filtered();
+ });
+ }
+ trackById = (_: number, t: { id: number }) => t.id;
+ add() {
+ const title = this.newTitle.trim();
+ if (!title) return;
+ const ids = this.store.ids() as Array;
+ const nextId = ids.length ? Math.max(...ids) + 1 : 1;
+ this.store.addTodo({ id: nextId, title, completed: false });
+ this.newTitle = '';
+ }
+}
diff --git a/apps/demo/src/app/todo-entity-resource/todo-entity-resource.store.ts b/apps/demo/src/app/todo-entity-resource/todo-entity-resource.store.ts
new file mode 100644
index 00000000..6d0883be
--- /dev/null
+++ b/apps/demo/src/app/todo-entity-resource/todo-entity-resource.store.ts
@@ -0,0 +1,61 @@
+import {
+ httpMutation,
+ withEntityResources,
+ withMutations,
+} from '@angular-architects/ngrx-toolkit';
+import { inject, resource } from '@angular/core';
+import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
+import { addEntity, removeEntity, updateEntity } from '@ngrx/signals/entities';
+import { firstValueFrom } from 'rxjs';
+import { Todo, TodoMemoryService } from './todo-memory.service';
+
+export const TodoEntityResourceStore = signalStore(
+ { providedIn: 'root' },
+ withState({ baseUrl: '/api', filter: '' }),
+ withEntityResources((store, svc = inject(TodoMemoryService)) =>
+ resource({ loader: () => firstValueFrom(svc.list()), defaultValue: [] }),
+ ),
+ withMethods((store) => ({
+ setFilter(filter: string) {
+ patchState(store, { filter });
+ },
+ })),
+ withMutations((store, svc = inject(TodoMemoryService)) => ({
+ addTodo: httpMutation({
+ request: (todo) => ({ url: '/memory/add', method: 'POST', body: todo }),
+ parse: (raw) => raw as Todo,
+ onSuccess: async (todo) => {
+ await firstValueFrom(svc.add(todo));
+ patchState(store, addEntity(todo));
+ },
+ }),
+ toggleTodo: httpMutation<{ id: number; completed: boolean }, Todo>({
+ request: (p) => ({
+ url: `/memory/toggle/${p.id}`,
+ method: 'PATCH',
+ body: p,
+ }),
+ parse: (raw) => raw as Todo,
+ onSuccess: async (_todo, p) => {
+ const todo = await firstValueFrom(svc.toggle(p.id, p.completed));
+ if (todo) {
+ patchState(
+ store,
+ updateEntity({
+ id: todo.id,
+ changes: { completed: todo.completed },
+ }),
+ );
+ }
+ },
+ }),
+ removeTodo: httpMutation({
+ request: (id) => ({ url: `/memory/remove/${id}`, method: 'DELETE' }),
+ parse: () => true,
+ onSuccess: async (_r, id) => {
+ await firstValueFrom(svc.remove(id));
+ patchState(store, removeEntity(id));
+ },
+ }),
+ })),
+);
diff --git a/apps/demo/src/app/todo-entity-resource/todo-memory.service.ts b/apps/demo/src/app/todo-entity-resource/todo-memory.service.ts
new file mode 100644
index 00000000..a86f654a
--- /dev/null
+++ b/apps/demo/src/app/todo-entity-resource/todo-memory.service.ts
@@ -0,0 +1,45 @@
+import { Injectable } from '@angular/core';
+import { BehaviorSubject, Observable, of } from 'rxjs';
+
+export interface Todo {
+ id: number;
+ title: string;
+ completed: boolean;
+}
+
+@Injectable({ providedIn: 'root' })
+export class TodoMemoryService {
+ private readonly todos$ = new BehaviorSubject([
+ { id: 1, title: 'Buy milk', completed: false },
+ { id: 2, title: 'Walk the dog', completed: true },
+ ]);
+
+ list(): Observable {
+ return this.todos$.asObservable();
+ }
+
+ add(todo: Todo): Observable {
+ const list = this.todos$.value.slice();
+ list.push(todo);
+ this.todos$.next(list);
+ return of(todo);
+ }
+
+ toggle(id: number, completed: boolean): Observable {
+ const list = this.todos$.value.slice();
+ const idx = list.findIndex((t) => t.id === id);
+ if (idx >= 0) {
+ list[idx] = { ...list[idx], completed };
+ this.todos$.next(list);
+ return of(list[idx]);
+ }
+ return of(undefined);
+ }
+
+ remove(id: number): Observable {
+ const list = this.todos$.value.slice();
+ const filtered = list.filter((t) => t.id !== id);
+ this.todos$.next(filtered);
+ return of(true);
+ }
+}
diff --git a/docs/docs/extensions.md b/docs/docs/extensions.md
index 94ad0e0c..702836b1 100644
--- a/docs/docs/extensions.md
+++ b/docs/docs/extensions.md
@@ -12,6 +12,7 @@ It offers extensions like:
- [Immutable State Protection](./with-immutable-state): Protects the state from being mutated outside or inside the Store.
- [~Redux~](./with-redux): Possibility to use the Redux Pattern. Deprecated in favor of NgRx's `@ngrx/signals/events` starting in 19.2
- [Resource](./with-resource): Integrates Angular's Resource into SignalStore for async data operations
+- [Entity Resources](./with-entity-resources): Builds on top of [withResource](./with-resource); adds entity support for array resources (`ids`, `entityMap`, `entities`)
- [Mutations](./mutations): Seek to offer an appropriate equivalent to signal resources for sending data back to the backend
- [Reset](./with-reset): Adds a `resetState` method to your store
- [Call State](./with-call-state): Add call state management to your signal stores
diff --git a/docs/docs/with-entity-resources.md b/docs/docs/with-entity-resources.md
new file mode 100644
index 00000000..f6c0b4bb
--- /dev/null
+++ b/docs/docs/with-entity-resources.md
@@ -0,0 +1,198 @@
+---
+title: withEntityResources()
+---
+
+```typescript
+import { withEntityResources } from '@angular-architects/ngrx-toolkit';
+```
+
+`withEntityResources()` integrates Angular Resources that return arrays into NgRx SignalStore using the Entity helpers from `@ngrx/signals/entities`.
+
+- **Unnamed resource**: Your store exposes resource members (`value`, `status`, `error`, `isLoading`, etc.) and additionally derives entity members: `ids`, `entityMap`, `entities`.
+- **Named resources**: Register multiple array resources by name. The store exposes prefixed members per resource, e.g. `todosValue`, `todosIds`, `todosEntityMap`, `todosEntities`.
+
+This feature composes `withResource()` and the Entities utilities without effects. Entity state is linked to the resource value using linked signals, so updaters like `addEntity`, `updateEntity`, and `removeEntity` mutate the entity view in the store while the source of truth remains the resource.
+
+## Accepted Inputs and Type Signatures
+
+```ts
+// Single (unnamed) resource producing an array of entities
+withEntityResources<
+ Entity extends { id: EntityId }
+>((store) => ResourceRef);
+
+// Multiple (named) resources: a dictionary of array resources
+withEntityResources<
+ Dictionary extends Record>
+>((store) => Dictionary);
+```
+
+- **Must be arrays**: Each `ResourceRef` must resolve to an array (possibly `readonly` and possibly `undefined` while loading). Use `defaultValue: []` for a consistent empty state.
+- **Entity identity**: Array element type must include an `id` compatible with `EntityId`.
+- **Named resources**: For the dictionary form, keys become the name prefixes (e.g., `todosEntities()`), and each entry can have a different element type.
+- **Non-array resources**: If your resource does not produce an array, use `withResource()` instead.
+
+## How it works internally
+
+- **Composes withResource**: Internally calls `withResource()` with either a single `ResourceRef` or a dictionary of `ResourceRef`s, so all standard Resource members are available on the store (or prefixed for named resources).
+- **Derives entity signals**: From the resource's `value` signal (the array), it derives:
+ - `ids` via a linked signal that maps each entity to its `id`
+ - `entityMap` via a linked signal that builds an `id -> entity` map
+ - `entities` as a computed projection of `ids` through `entityMap`
+- **No effects**: Synchronization is purely signal-based; entity updaters mutate the store's entity state while the underlying Resource value remains the source of truth.
+
+## Basic Usage
+
+### Unnamed Resource
+
+```typescript
+import { signalStore, withState, patchState } from '@ngrx/signals';
+import { resource } from '@angular/core';
+import { addEntity } from '@ngrx/signals/entities';
+import { withEntityResources } from '@angular-architects/ngrx-toolkit';
+
+export type Todo = { id: number; title: string; completed: boolean };
+
+export const TodoStore = signalStore(
+ { providedIn: 'root' },
+ withState({}),
+ withEntityResources(() => resource({ loader: () => Promise.resolve([] as Todo[]), defaultValue: [] })),
+);
+
+// Later, you can use entity updaters
+// patchState(TodoStore, addEntity({ id: 1, title: 'A', completed: false }));
+```
+
+The store now provides:
+
+- **Resource members**: `value()`, `status()`, `error()`, `isLoading()`, `hasValue()`, `_reload()`
+- **Entity members**: `ids()`, `entityMap()`, `entities()`
+
+### Named Resources
+
+```typescript
+import { signalStore } from '@ngrx/signals';
+import { resource } from '@angular/core';
+import { withEntityResources } from '@angular-architects/ngrx-toolkit';
+
+export type Todo = { id: number; title: string; completed: boolean };
+
+export const Store = signalStore(
+ { providedIn: 'root' },
+ withEntityResources(() => ({
+ todos: resource({ loader: () => Promise.resolve([] as Todo[]), defaultValue: [] }),
+ projects: resource({ loader: () => Promise.resolve([] as { id: number; name: string }[]), defaultValue: [] }),
+ })),
+);
+```
+
+This exposes per-resource members with the resource name as a prefix:
+
+- **Resource members**: `todosValue()`, `todosStatus()`, `todosError()`, `todosIsLoading()`; `projectsValue()`, ...
+- **Entity members**: `todosIds()`, `todosEntityMap()`, `todosEntities()`; `projectsIds()`, `projectsEntityMap()`, `projectsEntities()`
+
+## Component Usage
+
+```typescript
+import { Component, inject } from '@angular/core';
+
+@Component({
+ selector: 'todo-list',
+ template: `
+ @if (store.isLoading()) {
+ Loading...
+ } @else if (store.error()) {
+ An error has happened.
+ } @else if (store.hasValue()) {
+
+ @for (t of store.entities(); track t.id) {
+ - {{ t.title }} — {{ t.completed ? 'done' : 'open' }}
+ }
+
+ }
+ `,
+})
+export class TodoListComponent {
+ protected readonly store = inject(TodoStore);
+}
+```
+
+For a named collection like `todos`, use `todosIsLoading()`, `todosError()`, `todosEntities()`, etc.
+
+## Using Entity Updaters
+
+The derived entity state is writable via NgRx entity updaters, just like with `withEntities()`:
+
+```typescript
+import { patchState } from '@ngrx/signals';
+import { addEntity, updateEntity, removeEntity, setAllEntities } from '@ngrx/signals/entities';
+
+// Unnamed
+patchState(store, setAllEntities([{ id: 1, title: 'A', completed: false }]));
+patchState(store, addEntity({ id: 2, title: 'B', completed: true }));
+patchState(store, updateEntity({ id: 2, changes: { completed: false } }));
+patchState(store, removeEntity(1));
+
+// Named (e.g., todos)
+patchState(store, addEntity({ id: 3, title: 'C', completed: false }, { collection: 'todos' }));
+patchState(store, removeEntity(3, { collection: 'todos' }));
+```
+
+## Demo Example
+
+See the demo store `todo-entity-resource` for a full example that combines mutations and entity resources.
+
+```typescript
+import { httpMutation, withMutations, withEntityResources } from '@angular-architects/ngrx-toolkit';
+import { inject, resource } from '@angular/core';
+import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
+import { addEntity, removeEntity, updateEntity } from '@ngrx/signals/entities';
+import { firstValueFrom } from 'rxjs';
+import { Todo, TodoMemoryService } from './todo-memory.service';
+
+export const TodoEntityResourceStore = signalStore(
+ { providedIn: 'root' },
+ withState({ baseUrl: '/api', filter: '' }),
+ withEntityResources((_store, svc = inject(TodoMemoryService)) => resource({ loader: () => firstValueFrom(svc.list()), defaultValue: [] })),
+ withMethods((store) => ({
+ setFilter(filter: string) {
+ patchState(store, { filter });
+ },
+ })),
+ withMutations((store, svc = inject(TodoMemoryService)) => ({
+ addTodo: httpMutation({
+ request: (todo) => ({ url: '/memory/add', method: 'POST', body: todo }),
+ parse: (raw) => raw as Todo,
+ onSuccess: async (todo) => {
+ await firstValueFrom(svc.add(todo));
+ patchState(store, addEntity(todo));
+ },
+ }),
+ toggleTodo: httpMutation<{ id: number; completed: boolean }, Todo>({
+ request: (p) => ({ url: `/memory/toggle/${p.id}`, method: 'PATCH', body: p }),
+ parse: (raw) => raw as Todo,
+ onSuccess: async (_todo, p) => {
+ const todo = await firstValueFrom(svc.toggle(p.id, p.completed));
+ if (todo) {
+ patchState(store, updateEntity({ id: todo.id, changes: { completed: todo.completed } }));
+ }
+ },
+ }),
+ removeTodo: httpMutation({
+ request: (id) => ({ url: `/memory/remove/${id}`, method: 'DELETE' }),
+ parse: () => true,
+ onSuccess: async (_r, id) => {
+ await firstValueFrom(svc.remove(id));
+ patchState(store, removeEntity(id));
+ },
+ }),
+ })),
+);
+```
+
+## Interop and Notes
+
+- **Type Safety**: The entity type is inferred from the resource value (array element type). Ensure your resource returns an array type with an `id` field (`EntityId`).
+- **Composition**: Can be composed with `withEntities()` for additional collections alongside resource-backed collections.
+- **No effects**: Synchronization is purely signal-based via linked signals; no imperative effects are used.
+- **Named vs Unnamed**: Choose unnamed for a single list; use named when you manage multiple lists in one store.
diff --git a/docs/sidebars.ts b/docs/sidebars.ts
index a1dc30be..38b249c1 100644
--- a/docs/sidebars.ts
+++ b/docs/sidebars.ts
@@ -24,6 +24,7 @@ const sidebars: SidebarsConfig = {
'with-immutable-state',
'with-reset',
'with-resource',
+ 'with-entity-resources',
'with-storage-sync',
'with-undo-redo',
'with-redux',
diff --git a/libs/ngrx-toolkit/src/index.ts b/libs/ngrx-toolkit/src/index.ts
index 8b8bba85..6a03974d 100644
--- a/libs/ngrx-toolkit/src/index.ts
+++ b/libs/ngrx-toolkit/src/index.ts
@@ -43,6 +43,7 @@ export { emptyFeature, withConditional } from './lib/with-conditional';
export { withFeatureFactory } from './lib/with-feature-factory';
export * from './lib/mutation/rx-mutation';
+export * from './lib/with-entity-resources';
export * from './lib/with-mutations';
export { mapToResource, withResource } from './lib/with-resource';
diff --git a/libs/ngrx-toolkit/src/lib/with-entity-resources.spec.ts b/libs/ngrx-toolkit/src/lib/with-entity-resources.spec.ts
new file mode 100644
index 00000000..5830e6aa
--- /dev/null
+++ b/libs/ngrx-toolkit/src/lib/with-entity-resources.spec.ts
@@ -0,0 +1,258 @@
+import { resource } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { patchState, signalStore, withState } from '@ngrx/signals';
+import {
+ addEntity,
+ removeEntity,
+ setAllEntities,
+} from '@ngrx/signals/entities';
+import { withEntityResources } from './with-entity-resources';
+
+type Todo = { id: number; title: string; completed: boolean };
+const wait = async () => {
+ await new Promise((r) => setTimeout(r));
+};
+
+describe('withEntityResources', () => {
+ describe('unnamed entities', () => {
+ it('derives ids, entityMap and entities from resource value', async () => {
+ const Store = signalStore(
+ { providedIn: 'root', protectedState: false },
+ withState({ load: undefined as boolean | undefined }),
+ withEntityResources((store) =>
+ resource({
+ params: store.load,
+ loader: ({ params }) =>
+ Promise.resolve(
+ params
+ ? ([{ id: 1, title: 'A', completed: false }] as Todo[])
+ : ([] as Todo[]),
+ ),
+ defaultValue: [],
+ }),
+ ),
+ );
+
+ const store = TestBed.inject(Store);
+
+ // trigger load and verify derived signals
+ patchState(store, { load: true });
+ await wait();
+
+ expect(store.ids()).toEqual([1]);
+ expect(store.entityMap()).toEqual({
+ 1: { id: 1, title: 'A', completed: false },
+ });
+ expect(store.entities()).toEqual([
+ { id: 1, title: 'A', completed: false },
+ ]);
+ });
+
+ it('supports addEntity updater mutating ids/entityMap/derived entities', async () => {
+ const Store = signalStore(
+ { providedIn: 'root', protectedState: false },
+ withEntityResources(() =>
+ resource({
+ loader: () => Promise.resolve([] as Todo[]),
+ defaultValue: [],
+ }),
+ ),
+ );
+ const store = TestBed.inject(Store);
+
+ await wait();
+
+ expect(store.entities()).toEqual([]);
+
+ patchState(
+ store,
+ addEntity({ id: 1, title: 'X', completed: false } as Todo),
+ );
+
+ expect(store.ids()).toEqual([1]);
+ expect(store.entityMap()).toEqual({
+ 1: { id: 1, title: 'X', completed: false },
+ });
+ expect(store.entities()).toEqual([
+ { id: 1, title: 'X', completed: false },
+ ]);
+ });
+ });
+
+ describe('named entities', () => {
+ it('derives Ids, EntityMap and Entities from resource value', async () => {
+ const Store = signalStore(
+ { providedIn: 'root', protectedState: false },
+ withEntityResources(() => ({
+ todos: resource({
+ loader: () =>
+ Promise.resolve([
+ { id: 1, title: 'A', completed: false },
+ ] as Todo[]),
+ defaultValue: [],
+ }),
+ projects: resource({
+ loader: () =>
+ Promise.resolve([{ id: 10, name: 'X' }] as {
+ id: number;
+ name: string;
+ }[]),
+ defaultValue: [],
+ }),
+ })),
+ );
+
+ const store = TestBed.inject(Store);
+
+ await wait();
+
+ expect(store.todosIds()).toEqual([1]);
+ expect(store.todosEntityMap()).toEqual({
+ 1: { id: 1, title: 'A', completed: false },
+ });
+ expect(store.todosValue()).toEqual([
+ { id: 1, title: 'A', completed: false },
+ ]);
+ expect(store.projectsEntities()).toHaveLength(1);
+ expect(store.projectsValue()).toEqual([{ id: 10, name: 'X' }]);
+ });
+
+ it('supports addEntity for named collection via ids/entityMap', async () => {
+ const Store = signalStore(
+ { providedIn: 'root', protectedState: false },
+ withEntityResources(() => ({
+ todos: resource({
+ loader: () => Promise.resolve([] as Todo[]),
+ defaultValue: [],
+ }),
+ })),
+ );
+ const store = TestBed.inject(Store);
+
+ await wait();
+
+ expect(store.todosEntities()).toEqual([]);
+
+ patchState(
+ store,
+ addEntity(
+ { id: 2, title: 'Y', completed: true },
+ { collection: 'todos' },
+ ),
+ );
+
+ expect(store.todosIds()).toEqual([2]);
+ expect(store.todosEntityMap()).toEqual({
+ 2: { id: 2, title: 'Y', completed: true },
+ });
+ expect(store.todosEntities()).toEqual([
+ { id: 2, title: 'Y', completed: true },
+ ]);
+ });
+ });
+
+ describe('entity updaters', () => {
+ it('supports setAllEntities/addEntity/removeEntity for unnamed', async () => {
+ const Store = signalStore(
+ { providedIn: 'root', protectedState: false },
+ withEntityResources(() =>
+ resource({
+ loader: () => Promise.resolve([] as Todo[]),
+ defaultValue: [],
+ }),
+ ),
+ );
+ const store = TestBed.inject(Store);
+
+ await wait();
+
+ // set all
+ patchState(
+ store,
+ setAllEntities([
+ { id: 1, title: 'A', completed: false },
+ { id: 2, title: 'B', completed: true },
+ ] as Todo[]),
+ );
+ expect(store.ids()).toEqual([1, 2]);
+ expect(store.entities()).toEqual([
+ { id: 1, title: 'A', completed: false },
+ { id: 2, title: 'B', completed: true },
+ ]);
+
+ // add
+ patchState(
+ store,
+ addEntity({ id: 3, title: 'C', completed: false } as Todo),
+ );
+ expect(store.ids()).toEqual([1, 2, 3]);
+ expect(store.entities()).toEqual([
+ { id: 1, title: 'A', completed: false },
+ { id: 2, title: 'B', completed: true },
+ { id: 3, title: 'C', completed: false },
+ ]);
+
+ // remove
+ patchState(store, removeEntity(2));
+ expect(store.ids()).toEqual([1, 3]);
+ expect(store.entities()).toEqual([
+ { id: 1, title: 'A', completed: false },
+ { id: 3, title: 'C', completed: false },
+ ]);
+ });
+
+ it('supports setAllEntities/addEntity/removeEntity for named', async () => {
+ const Store = signalStore(
+ { providedIn: 'root', protectedState: false },
+ withEntityResources(() => ({
+ todos: resource({
+ loader: () => Promise.resolve([] as Todo[]),
+ defaultValue: [],
+ }),
+ })),
+ );
+ const store = TestBed.inject(Store);
+
+ await wait();
+
+ // set all
+ patchState(
+ store,
+ setAllEntities(
+ [
+ { id: 10, title: 'X', completed: false },
+ { id: 11, title: 'Y', completed: true },
+ ] as Todo[],
+ { collection: 'todos' },
+ ),
+ );
+ expect(store.todosIds()).toEqual([10, 11]);
+ expect(store.todosEntities()).toEqual([
+ { id: 10, title: 'X', completed: false },
+ { id: 11, title: 'Y', completed: true },
+ ]);
+
+ // add
+ patchState(
+ store,
+ addEntity({ id: 12, title: 'Z', completed: false } as Todo, {
+ collection: 'todos',
+ }),
+ );
+ expect(store.todosIds()).toEqual([10, 11, 12]);
+ expect(store.todosEntities()).toEqual([
+ { id: 10, title: 'X', completed: false },
+ { id: 11, title: 'Y', completed: true },
+ { id: 12, title: 'Z', completed: false },
+ ]);
+
+ // remove
+ patchState(store, removeEntity(11, { collection: 'todos' }));
+ expect(store.todosIds()).toEqual([10, 12]);
+ expect(store.todosEntities()).toEqual([
+ { id: 10, title: 'X', completed: false },
+ { id: 12, title: 'Z', completed: false },
+ ]);
+ });
+ });
+});
diff --git a/libs/ngrx-toolkit/src/lib/with-entity-resources.ts b/libs/ngrx-toolkit/src/lib/with-entity-resources.ts
new file mode 100644
index 00000000..84f571b8
--- /dev/null
+++ b/libs/ngrx-toolkit/src/lib/with-entity-resources.ts
@@ -0,0 +1,305 @@
+import { ResourceRef, Signal, computed, linkedSignal } from '@angular/core';
+import {
+ SignalStoreFeature,
+ SignalStoreFeatureResult,
+ StateSignals,
+ signalStoreFeature,
+ withComputed,
+ withLinkedState,
+} from '@ngrx/signals';
+import {
+ EntityId,
+ EntityProps,
+ EntityState,
+ NamedEntityProps,
+ NamedEntityState,
+} from '@ngrx/signals/entities';
+import {
+ NamedResourceResult,
+ ResourceResult,
+ isResourceRef,
+ withResource,
+} from './with-resource';
+
+/**
+ * @experimental
+ * @description
+ *
+ * Integrates array-based `Resource` data into Entity-style state for NgRx SignalStore.
+ *
+ * - For a single (unnamed) resource: exposes `value`, `status`, `error`, `isLoading`
+ * from the underlying resource (via `withResource`), and derives
+ * `ids`, `entityMap`, and `entities` via `withLinkedState`/`withComputed`.
+ * - For multiple (named) resources: registers each resource by name and exposes
+ * the same members prefixed with the resource name, e.g. `todosIds`,
+ * `todosEntityMap`, `todosEntities`, along with `todosValue`, `todosStatus`, etc.
+ *
+ * No effects are used. All derived signals are linked to the resource's value
+ * through `withLinkedState`, so entity updaters such as `addEntity`, `updateEntity`,
+ * and `removeEntity` mutate the store's entity view without directly writing to the
+ * resource. The source of truth remains the resource value.
+ *
+ * @usageNotes
+ *
+ * Unnamed resource example:
+ *
+ * ```ts
+ * type Todo = { id: number; title: string; completed: boolean };
+ *
+ * const Store = signalStore(
+ * { providedIn: 'root' },
+ * withEntityResources(() =>
+ * resource({ loader: () => Promise.resolve([] as Todo[]), defaultValue: [] }),
+ * ),
+ * );
+ *
+ * const store = TestBed.inject(Store);
+ * store.status(); // 'idle' | 'loading' | 'resolved' | 'error'
+ * store.value(); // Todo[]
+ * store.ids(); // EntityId[]
+ * store.entityMap(); // Record
+ * store.entities(); // Todo[]
+ *
+ * // Works with @ngrx/signals/entities updaters
+ * patchState(store, addEntity({ id: 1, title: 'X', completed: false }));
+ * ```
+ *
+ * Named resources example:
+ *
+ * ```ts
+ * const Store = signalStore(
+ * { providedIn: 'root' },
+ * withEntityResources(() => ({
+ * todos: resource({ loader: () => Promise.resolve([] as Todo[]), defaultValue: [] }),
+ * projects: resource({ loader: () => Promise.resolve([] as { id: number; name: string }[]), defaultValue: [] }),
+ * })),
+ * );
+ *
+ * const store = TestBed.inject(Store);
+ * store.todosValue();
+ * store.todosIds();
+ * store.todosEntityMap();
+ * store.todosEntities();
+ * patchState(store, addEntity({ id: 2, title: 'Y', completed: true }, { collection: 'todos' }));
+ * ```
+ */
+export function withEntityResources<
+ Input extends SignalStoreFeatureResult,
+ Entity extends { id: EntityId },
+>(
+ resourceFactory: (
+ store: Input['props'] & Input['methods'] & StateSignals,
+ ) => ResourceRef,
+): SignalStoreFeature>;
+
+export function withEntityResources<
+ Input extends SignalStoreFeatureResult,
+ Dictionary extends EntityDictionary,
+>(
+ resourceFactory: (
+ store: Input['props'] & Input['methods'] & StateSignals,
+ ) => Dictionary,
+): SignalStoreFeature>;
+
+export function withEntityResources<
+ Input extends SignalStoreFeatureResult,
+ ResourceValue extends readonly unknown[] | unknown[] | undefined,
+>(
+ entityResourceFactory: (
+ store: Input['props'] & Input['methods'] & StateSignals,
+ ) => ResourceRef | EntityDictionary,
+): SignalStoreFeature {
+ return (store) => {
+ const resourceOrDict = entityResourceFactory({
+ ...store.stateSignals,
+ ...store.props,
+ ...store.methods,
+ });
+
+ if (isResourceRef(resourceOrDict)) {
+ return createUnnamedEntityResource(resourceOrDict)(store);
+ }
+ return createNamedEntityResources(resourceOrDict)(store);
+ };
+}
+
+function createUnnamedEntityResource<
+ R extends ResourceRef,
+>(resource: R) {
+ type E = InferEntityFromRef & { id: EntityId };
+ const { idsLinked, entityMapLinked, entitiesSignal } =
+ createEntityDerivations(
+ resource.value as Signal,
+ );
+
+ return signalStoreFeature(
+ withResource(() => resource),
+ withLinkedState(() => ({
+ entityMap: entityMapLinked,
+ ids: idsLinked,
+ })),
+ withComputed(() => ({
+ entities: entitiesSignal,
+ })),
+ );
+}
+
+function createNamedEntityResources(
+ dictionary: Dictionary,
+) {
+ const keys = Object.keys(dictionary);
+
+ const linkedState: Record> = {};
+ const computedProps: Record> = {};
+
+ keys.forEach((name) => {
+ const ref = dictionary[name];
+ type E = InferEntityFromRef & { id: EntityId };
+ const { idsLinked, entityMapLinked, entitiesSignal } =
+ createEntityDerivations(
+ ref.value as Signal,
+ );
+
+ linkedState[`${String(name)}EntityMap`] = entityMapLinked;
+ linkedState[`${String(name)}Ids`] = idsLinked;
+ computedProps[`${String(name)}Entities`] = entitiesSignal;
+ });
+
+ return signalStoreFeature(
+ withResource(() => dictionary),
+ withLinkedState(() => linkedState),
+ withComputed(() => computedProps),
+ );
+}
+
+// Types for `withEntityResources`
+/**
+ * @internal
+ * @description
+ *
+ * Type composition notes: we intentionally do not duplicate or re-declare
+ * types that already exist in `@ngrx/signals/entities` or in this library's
+ * `with-resource` feature. Instead, we compose the resulting API via
+ * intersections of those public contracts.
+ *
+ * Rationale:
+ * - Keeps our types in sync with upstream sources and avoids drift.
+ * - Reduces maintenance overhead and duplication.
+ * - Ensures consumers benefit automatically from upstream typing fixes.
+ *
+ * Concretely:
+ * - For unnamed resources we return `ResourceResult` intersected with
+ * `EntityState` and `EntityProps`.
+ * - For named resources we return `NamedResourceResult` intersected with
+ * `NamedEntityState` and `NamedEntityProps` for each entry.
+ */
+export type EntityResourceResult = {
+ state: ResourceResult['state'] & EntityState;
+ props: ResourceResult['props'] & EntityProps;
+ methods: ResourceResult['methods'];
+};
+
+// Generic helpers for inferring entity types and merging unions
+type ArrayElement = T extends readonly (infer E)[] | (infer E)[] ? E : never;
+
+type InferEntityFromSignal =
+ T extends Signal ? ArrayElement : never;
+
+type InferEntityFromRef<
+ R extends ResourceRef,
+> = R['value'] extends Signal ? ArrayElement : never;
+
+type MergeUnion = (U extends unknown ? (k: U) => void : never) extends (
+ k: infer I,
+) => void
+ ? I
+ : never;
+
+export type EntityDictionary = Record<
+ string,
+ ResourceRef
+>;
+
+type MergeNamedEntityStates = MergeUnion<
+ {
+ [Prop in keyof T]: Prop extends string
+ ? InferEntityFromSignal extends infer E
+ ? E extends never
+ ? never
+ : NamedEntityState
+ : never
+ : never;
+ }[keyof T]
+>;
+
+type MergeNamedEntityProps = MergeUnion<
+ {
+ [Prop in keyof T]: Prop extends string
+ ? InferEntityFromSignal extends infer E
+ ? E extends never
+ ? never
+ : NamedEntityProps
+ : never
+ : never;
+ }[keyof T]
+>;
+
+export type NamedEntityResourceResult = {
+ state: NamedResourceResult['state'] & MergeNamedEntityStates;
+ props: NamedResourceResult['props'] & MergeNamedEntityProps;
+ methods: NamedResourceResult['methods'];
+};
+
+/**
+ * @internal
+ * @description
+ *
+ * Creates the three entity-related signals (`ids`, `entityMap`, `entities`) from
+ * a single source signal of entities. This mirrors the public contract of
+ * `withEntities()`:
+ * - `ids`: derived list of entity ids
+ * - `entityMap`: map of id -> entity
+ * - `entities`: projection of `ids` through `entityMap`
+ *
+ * Implementation details:
+ * - Uses `withLinkedState` + `linkedSignal` for `ids` and `entityMap` so they are
+ * writable state signals in the store (updaters like `addEntity` can mutate them).
+ * - `entities` is a pure `computed` derived from `ids` and `entityMap`, matching
+ * the pattern in `withEntities` where `entities` is computed from the two bases.
+ *
+ * Why not `watchState` or `effect`?
+ * - `watchState` would fire for any state change in the store, not just changes
+ * to the underlying resource value. That would cause unnecessary recomputation
+ * and make it harder to reason about updates.
+ * - `effect` would introduce side-effects and lifecycle management for syncing,
+ * which is heavier and not aligned with this feature's goal to be purely
+ * derived from signals. Using linked signals keeps the data flow declarative
+ * and avoids imperative syncing code.
+ */
+function createEntityDerivations(
+ source: Signal,
+) {
+ const idsLinked = linkedSignal({
+ source,
+ computation: (list) => (list ?? []).map((e) => e.id),
+ });
+
+ const entityMapLinked = linkedSignal({
+ source,
+ computation: (list) => {
+ const map = {} as Record;
+ for (const item of list ?? []) {
+ map[item.id] = item as E;
+ }
+ return map;
+ },
+ });
+
+ const entitiesSignal = computed(() => {
+ const ids = idsLinked();
+ const map = entityMapLinked();
+ return ids.map((id) => map[id]) as readonly E[];
+ });
+
+ return { idsLinked, entityMapLinked, entitiesSignal };
+}
diff --git a/libs/ngrx-toolkit/src/lib/with-resource.ts b/libs/ngrx-toolkit/src/lib/with-resource.ts
index 4f57a5a3..c2011b29 100644
--- a/libs/ngrx-toolkit/src/lib/with-resource.ts
+++ b/libs/ngrx-toolkit/src/lib/with-resource.ts
@@ -31,9 +31,9 @@ export type ResourceResult = {
};
};
-type ResourceDictionary = Record>;
+export type ResourceDictionary = Record>;
-type NamedResourceResult = {
+export type NamedResourceResult = {
state: {
[Prop in keyof T as `${Prop &
string}Value`]: T[Prop]['value'] extends Signal ? S : never;
@@ -223,7 +223,7 @@ function createNamedResource(
);
}
-function isResourceRef(value: unknown): value is ResourceRef {
+export function isResourceRef(value: unknown): value is ResourceRef {
return (
value !== null &&
typeof value === 'object' &&