diff --git a/libs/composables/attribute/README.md b/libs/composables/attribute/README.md
index 2c7e14f..1f8b481 100644
--- a/libs/composables/attribute/README.md
+++ b/libs/composables/attribute/README.md
@@ -1,10 +1,3 @@
# @bynary/composables/attribute
Secondary entry point of `@bynary/composables`. It can be used by importing from `@bynary/composables/attribute`.
-
-## Composables:
-
-| Name | Purpose |
-|-------------------------------------------------------------|------------------------------------------------------|
-| [`Attribute`](docs/attribute.composable.md) | Get and set the value of an attribute of an element. |
-| [`Boolean Attribute`](docs/boolean-attribute.composable.md) | Get and set the value of a boolean attribute. |
diff --git a/libs/composables/ngxs/README.md b/libs/composables/ngxs/README.md
new file mode 100644
index 0000000..a16e421
--- /dev/null
+++ b/libs/composables/ngxs/README.md
@@ -0,0 +1,3 @@
+# @bynary/composables/ngxs
+
+Secondary entry point of `@bynary/composables`. It can be used by importing from `@bynary/composables/ngxs`.
diff --git a/libs/composables/ngxs/ng-package.json b/libs/composables/ngxs/ng-package.json
new file mode 100644
index 0000000..78e382c
--- /dev/null
+++ b/libs/composables/ngxs/ng-package.json
@@ -0,0 +1,5 @@
+{
+ "lib": {
+ "entryFile": "src/index.ts"
+ }
+}
diff --git a/libs/composables/ngxs/src/index.ts b/libs/composables/ngxs/src/index.ts
new file mode 100644
index 0000000..7e1a213
--- /dev/null
+++ b/libs/composables/ngxs/src/index.ts
@@ -0,0 +1 @@
+export * from './public-api';
diff --git a/libs/composables/ngxs/src/public-api.ts b/libs/composables/ngxs/src/public-api.ts
new file mode 100644
index 0000000..08356b1
--- /dev/null
+++ b/libs/composables/ngxs/src/public-api.ts
@@ -0,0 +1,29 @@
+/**
+ * @packageDocumentation
+ * Composables for [NGXS](https://www.ngxs.io/).
+ *
+ * @example
+ * ```ts
+ * @Component({
+ * template: `
+ * My books:
+ *
+ * `
+ * })
+ * class BooksComponent {
+ *
+ * // legacy way using an Observable
+ * @Select(BooksState.books)
+ * books$: Observable;
+ *
+ * // new way using a signal
+ * books = useSelect(BooksState.books);
+ * }
+ * ```
+ *
+ * @module @bynary/composables/attribute
+ */
+
+export * from './select.composable';
diff --git a/libs/composables/ngxs/src/select.composable.spec.ts b/libs/composables/ngxs/src/select.composable.spec.ts
new file mode 100644
index 0000000..0d6c133
--- /dev/null
+++ b/libs/composables/ngxs/src/select.composable.spec.ts
@@ -0,0 +1,92 @@
+import { inject, Injectable, isSignal } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { Action, NgxsModule, Selector, State, StateContext, Store } from '@ngxs/store';
+
+import { useSelect } from './select.composable';
+
+interface IBookStateModel {
+ books: string[];
+}
+
+class AddBook {
+ static readonly type = '[Books] Add';
+
+ constructor(public book: string) {
+ }
+}
+
+@State({
+ name: 'books',
+ defaults: {
+ books: [ 'The Hobbit' ]
+ }
+})
+@Injectable()
+class BooksState {
+
+ @Selector()
+ static books(state: IBookStateModel) {
+ return state.books;
+ }
+
+ @Action(AddBook)
+ addBook(ctx: StateContext, action: AddBook) {
+ ctx.patchState({ books: [ ...ctx.getState().books, action.book ] });
+ }
+}
+
+describe('select composable', () => {
+
+ describe('useSelect', () => {
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [ NgxsModule.forRoot([ BooksState ]) ]
+ });
+ });
+
+ it('should be contained in state.utils', async () => {
+ const module = await import('./select.composable');
+
+ expect(module).toHaveProperty('useSelect');
+ expect(typeof module.useSelect).toBe('function');
+ });
+
+ it('should return a Signal', async () => {
+ TestBed.runInInjectionContext(() => {
+ const result = useSelect(BooksState.books);
+
+ expect(isSignal(result)).toBe(true);
+ expect(result()).toEqual([ 'The Hobbit' ]);
+ });
+ });
+
+ it('should pass the selector to Store.select', async () => {
+ TestBed.runInInjectionContext(() => {
+ const store = inject(Store);
+ const selectSpy = jest.spyOn(store, 'select');
+
+ useSelect(BooksState.books);
+
+ expect(selectSpy).toHaveBeenCalledTimes(1);
+ expect(selectSpy).toHaveBeenCalledWith(BooksState.books);
+
+ selectSpy.mockReset();
+ });
+ });
+
+ it('the returned Signal should update when the state changes', async () => {
+ TestBed.runInInjectionContext(() => {
+ const store = inject(Store);
+
+ const result = useSelect(BooksState.books);
+
+ expect(result()).toEqual([ 'The Hobbit' ]);
+
+ store.dispatch(new AddBook('The Lord of the Rings'));
+
+ expect(result()).toEqual([ 'The Hobbit', 'The Lord of the Rings' ]);
+ });
+ });
+ });
+});
diff --git a/libs/composables/ngxs/src/select.composable.ts b/libs/composables/ngxs/src/select.composable.ts
new file mode 100644
index 0000000..eed05ff
--- /dev/null
+++ b/libs/composables/ngxs/src/select.composable.ts
@@ -0,0 +1,43 @@
+import { inject, Signal, Type } from '@angular/core';
+import { toSignal } from '@angular/core/rxjs-interop';
+import { Store } from '@ngxs/store';
+import { StateToken } from '@ngxs/store/src/state-token/state-token';
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+export function useSelect(selector: (state: any, ...states: any[]) => T): Signal;
+export function useSelect(selector: string | Type): Signal;
+export function useSelect(selector: StateToken): Signal;
+/**
+ * Selects a slice of data from the store.
+ * Uses {@link Store#select} internally and converts its observable to a signal.
+ *
+ * @example
+ * ```ts
+ * @Component({
+ * template: `
+ * My books:
+ *
+ * `
+ * })
+ * class BooksComponent {
+ *
+ * // legacy way using an Observable
+ * @Select(BooksState.books)
+ * books$: Observable;
+ *
+ * // new way using a signal
+ * books = useSelect(BooksState.books);
+ * }
+ * ```
+ *
+ * @param selector The selector function or key
+ */
+export function useSelect(selector: any) {
+ const store = inject(Store);
+
+ return toSignal(store.select(selector));
+}
+
+/* eslint-enable @typescript-eslint/no-explicit-any */
diff --git a/libs/composables/package.json b/libs/composables/package.json
index 48d4421..61d337b 100644
--- a/libs/composables/package.json
+++ b/libs/composables/package.json
@@ -31,6 +31,7 @@
"@angular/core": ">=16.0.0 <18.0.0",
"@angular/cdk": ">=16.0.0 <18.0.0",
"@angular/platform-browser": ">=16.0.0 <18.0.0",
+ "@ngxs/store": "^3.8.1",
"rxjs": ">=7.0.0"
},
"dependencies": {},
diff --git a/libs/composables/project.json b/libs/composables/project.json
index 71a82f6..06f3d53 100644
--- a/libs/composables/project.json
+++ b/libs/composables/project.json
@@ -55,7 +55,9 @@
"libs/composables/title/**/*.ts",
"libs/composables/title/**/*.html",
"libs/composables/theme/**/*.ts",
- "libs/composables/theme/**/*.html"
+ "libs/composables/theme/**/*.html",
+ "libs/composables/ngxs/**/*.ts",
+ "libs/composables/ngxs/**/*.html"
]
}
}
diff --git a/package-lock.json b/package-lock.json
index 1a80405..69aaf75 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
"@angular/platform-browser": "16.2.9",
"@angular/platform-browser-dynamic": "16.2.9",
"@angular/router": "16.2.9",
+ "@ngxs/store": "^3.8.1",
"@nx/angular": "17.0.2",
"@nx/devkit": "17.0.2",
"@swc/helpers": "0.5.3",
@@ -4589,6 +4590,22 @@
"webpack": "^5.54.0"
}
},
+ "node_modules/@ngxs/store": {
+ "version": "3.8.1",
+ "resolved": "https://registry.npmjs.org/@ngxs/store/-/store-3.8.1.tgz",
+ "integrity": "sha512-nbapLdMx+mtnb57BUWXbD6qYfVICv6Rp2NdoGx1++qDbc44ALC49KbF7rSjyPltlExxharAzoNpzO3JuueCP+A==",
+ "dependencies": {
+ "tslib": "^2.2.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/ngxs"
+ },
+ "peerDependencies": {
+ "@angular/core": ">=12.0.0 <17.0.0",
+ "rxjs": ">=6.5.5"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
diff --git a/package.json b/package.json
index 3524d77..0c5fea1 100644
--- a/package.json
+++ b/package.json
@@ -41,6 +41,7 @@
"@angular/platform-browser": "16.2.9",
"@angular/platform-browser-dynamic": "16.2.9",
"@angular/router": "16.2.9",
+ "@ngxs/store": "^3.8.1",
"@nx/angular": "17.0.2",
"@nx/devkit": "17.0.2",
"@swc/helpers": "0.5.3",
diff --git a/tsconfig.base.json b/tsconfig.base.json
index d3827d4..bc5f0b9 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -10,7 +10,10 @@
"importHelpers": true,
"target": "es2015",
"module": "esnext",
- "lib": ["es2020", "dom"],
+ "lib": [
+ "es2020",
+ "dom"
+ ],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"baseUrl": ".",
@@ -21,14 +24,22 @@
"@bynary/composables/class": [
"libs/composables/class/src/index.ts"
],
+ "@bynary/composables/ngxs": [
+ "libs/composables/ngxs/src/index.ts"
+ ],
"@bynary/composables/observer": [
"libs/composables/observer/src/index.ts"
],
"@bynary/composables/storage": [
"libs/composables/storage/src/index.ts"
],
- "@bynary/composables/title": ["libs/composables/title/src/index.ts"]
+ "@bynary/composables/title": [
+ "libs/composables/title/src/index.ts"
+ ]
}
},
- "exclude": ["node_modules", "tmp"]
+ "exclude": [
+ "node_modules",
+ "tmp"
+ ]
}