diff --git a/apps/demo/src/app/app.component.html b/apps/demo/src/app/app.component.html
index e6dba2d..09f0fcd 100644
--- a/apps/demo/src/app/app.component.html
+++ b/apps/demo/src/app/app.component.html
@@ -1,5 +1,4 @@
-
@@ -20,5 +19,4 @@
-
diff --git a/apps/demo/src/app/app.routes.ts b/apps/demo/src/app/app.routes.ts
index 8e05941..7d26ae6 100644
--- a/apps/demo/src/app/app.routes.ts
+++ b/apps/demo/src/app/app.routes.ts
@@ -5,16 +5,24 @@ import { FlightSearchSimpleComponent } from './flight-search-data-service-simple
import { FlightEditSimpleComponent } from './flight-search-data-service-simple/flight-edit-simple.component';
import { FlightSearchDynamicComponent } from './flight-search-data-service-dynamic/flight-search.component';
import { FlightEditDynamicComponent } from './flight-search-data-service-dynamic/flight-edit.component';
+import { TodoStorageSyncComponent } from './todo-storage-sync/todo-storage-sync.component';
import { FlightSearchReducConnectorComponent } from './flight-search-redux-connector/flight-search.component';
import { provideFlightStore } from './flight-search-redux-connector/+state/redux';
export const appRoutes: Route[] = [
{ path: 'todo', component: TodoComponent },
{ path: 'flight-search', component: FlightSearchComponent },
- { path: 'flight-search-data-service-simple', component: FlightSearchSimpleComponent },
+ {
+ path: 'flight-search-data-service-simple',
+ component: FlightSearchSimpleComponent,
+ },
{ path: 'flight-edit-simple/:id', component: FlightEditSimpleComponent },
- { path: 'flight-search-data-service-dynamic', component: FlightSearchDynamicComponent },
+ {
+ path: 'flight-search-data-service-dynamic',
+ component: FlightSearchDynamicComponent,
+ },
{ path: 'flight-edit-dynamic/:id', component: FlightEditDynamicComponent },
+ { path: 'todo-storage-sync', component: TodoStorageSyncComponent },
{
path: 'flight-search-redux-connector',
providers: [provideFlightStore()],
diff --git a/apps/demo/src/app/todo-storage-sync/synced-todo-store.ts b/apps/demo/src/app/todo-storage-sync/synced-todo-store.ts
new file mode 100644
index 0000000..bf4f1f4
--- /dev/null
+++ b/apps/demo/src/app/todo-storage-sync/synced-todo-store.ts
@@ -0,0 +1,37 @@
+import { patchState, signalStore, withMethods } from '@ngrx/signals';
+import {
+ withEntities,
+ setEntity,
+ removeEntity,
+ updateEntity,
+} from '@ngrx/signals/entities';
+import { AddTodo, Todo } from '../todo-store';
+import { withStorageSync } from 'ngrx-toolkit';
+
+export const SyncedTodoStore = signalStore(
+ { providedIn: 'root' },
+ withEntities(),
+ withStorageSync({
+ key: 'todos',
+ }),
+ withMethods((store) => {
+ let currentId = 0;
+ return {
+ add(todo: AddTodo) {
+ patchState(store, setEntity({ id: ++currentId, ...todo }));
+ },
+
+ remove(id: number) {
+ patchState(store, removeEntity(id));
+ },
+
+ toggleFinished(id: number): void {
+ const todo = store.entityMap()[id];
+ patchState(
+ store,
+ updateEntity({ id, changes: { finished: !todo.finished } })
+ );
+ },
+ };
+ })
+);
diff --git a/apps/demo/src/app/todo-storage-sync/todo-storage-sync.component.html b/apps/demo/src/app/todo-storage-sync/todo-storage-sync.component.html
new file mode 100644
index 0000000..d3ecb59
--- /dev/null
+++ b/apps/demo/src/app/todo-storage-sync/todo-storage-sync.component.html
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+ delete
+
+
+
+
+
+ Name
+ {{ element.name }}
+
+
+
+
+ Description
+ {{ element.description }}
+
+
+
+
+ Deadline
+ {{
+ element.deadline
+ }}
+
+
+
+
+
diff --git a/apps/demo/src/app/todo-storage-sync/todo-storage-sync.component.scss b/apps/demo/src/app/todo-storage-sync/todo-storage-sync.component.scss
new file mode 100644
index 0000000..e69de29
diff --git a/apps/demo/src/app/todo-storage-sync/todo-storage-sync.component.ts b/apps/demo/src/app/todo-storage-sync/todo-storage-sync.component.ts
new file mode 100644
index 0000000..d8146b3
--- /dev/null
+++ b/apps/demo/src/app/todo-storage-sync/todo-storage-sync.component.ts
@@ -0,0 +1,38 @@
+import { Component, effect, inject } from '@angular/core';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { MatIconModule } from '@angular/material/icon';
+import { MatTableDataSource, MatTableModule } from '@angular/material/table';
+import { SyncedTodoStore } from './synced-todo-store';
+import { SelectionModel } from '@angular/cdk/collections';
+import { CategoryStore } from '../category.store';
+import { Todo } from '../todo-store';
+
+@Component({
+ selector: 'demo-todo-storage-sync',
+ standalone: true,
+ imports: [MatCheckboxModule, MatIconModule, MatTableModule],
+ templateUrl: './todo-storage-sync.component.html',
+ styleUrl: './todo-storage-sync.component.scss',
+})
+export class TodoStorageSyncComponent {
+ todoStore = inject(SyncedTodoStore);
+ categoryStore = inject(CategoryStore);
+
+ displayedColumns: string[] = ['finished', 'name', 'description', 'deadline'];
+ dataSource = new MatTableDataSource([]);
+ selection = new SelectionModel(true, []);
+
+ constructor() {
+ effect(() => {
+ this.dataSource.data = this.todoStore.entities();
+ });
+ }
+
+ checkboxLabel(todo: Todo) {
+ this.todoStore.toggleFinished(todo.id);
+ }
+
+ removeTodo(todo: Todo) {
+ this.todoStore.remove(todo.id);
+ }
+}
diff --git a/apps/demo/src/app/todo-store.ts b/apps/demo/src/app/todo-store.ts
index 1baea17..115bc46 100644
--- a/apps/demo/src/app/todo-store.ts
+++ b/apps/demo/src/app/todo-store.ts
@@ -15,7 +15,7 @@ export interface Todo {
deadline?: Date;
}
-type AddTodo = Omit;
+export type AddTodo = Omit;
export const TodoStore = signalStore(
{ providedIn: 'root' },
diff --git a/libs/ngrx-toolkit/README.md b/libs/ngrx-toolkit/README.md
index cc317c8..e8f1218 100644
--- a/libs/ngrx-toolkit/README.md
+++ b/libs/ngrx-toolkit/README.md
@@ -40,7 +40,7 @@ This extension is very easy to use. Just add it to a `signalStore`. Example:
export const FlightStore = signalStore(
{ providedIn: 'root' },
withDevtools('flights'), // <-- add this
- withState({ flights: [] as Flight[] }),
+ withState({ flights: [] as Flight[] })
// ...
);
```
@@ -76,18 +76,15 @@ export const FlightStore = signalStore(
return {
load$: create(actions.load).pipe(
switchMap(({ from, to }) =>
- httpClient.get(
- 'https://demo.angulararchitects.io/api/flight',
- {
- params: new HttpParams().set('from', from).set('to', to),
- },
- ),
+ httpClient.get('https://demo.angulararchitects.io/api/flight', {
+ params: new HttpParams().set('from', from).set('to', to),
+ })
),
- tap((flights) => actions.loaded({ flights })),
+ tap((flights) => actions.loaded({ flights }))
),
};
},
- }),
+ })
);
```
@@ -103,18 +100,18 @@ export const SimpleFlightBookingStore = signalStore(
withCallState(),
withEntities(),
withDataService({
- dataServiceType: FlightService,
+ dataServiceType: FlightService,
filter: { from: 'Paris', to: 'New York' },
}),
- withUndoRedo(),
+ withUndoRedo()
);
```
-The features ``withCallState`` and ``withUndoRedo`` are optional, but when present, they enrich each other.
+The features `withCallState` and `withUndoRedo` are optional, but when present, they enrich each other.
-The Data Service needs to implement the ``DataService`` interface:
+The Data Service needs to implement the `DataService` interface:
-```typescript
+```typescript
@Injectable({
providedIn: 'root'
})
@@ -172,30 +169,30 @@ export class FlightSearchSimpleComponent {
## DataService with Dynamic Properties
-To avoid naming conflicts, the properties set up by ``withDataService`` and the connected features can be configured in a typesafe way:
+To avoid naming conflicts, the properties set up by `withDataService` and the connected features can be configured in a typesafe way:
```typescript
export const FlightBookingStore = signalStore(
{ providedIn: 'root' },
withCallState({
- collection: 'flight'
+ collection: 'flight',
}),
- withEntities({
- entity: type(),
- collection: 'flight'
+ withEntities({
+ entity: type(),
+ collection: 'flight',
}),
withDataService({
- dataServiceType: FlightService,
+ dataServiceType: FlightService,
filter: { from: 'Graz', to: 'Hamburg' },
- collection: 'flight'
+ collection: 'flight',
}),
withUndoRedo({
collections: ['flight'],
- }),
+ })
);
```
-This setup makes them use ``flight`` as part of the used property names. As these implementations respect the Type Script type system, the compiler will make sure these properties are used in a typesafe way:
+This setup makes them use `flight` as part of the used property names. As these implementations respect the Type Script type system, the compiler will make sure these properties are used in a typesafe way:
```typescript
@Component(...)
@@ -236,6 +233,44 @@ export class FlightSearchDynamicComponent {
}
```
+## Storage Sync `withStorageSync()`
+
+`withStorageSync` adds automatic or manual synchronization with Web Storage (`localstorage`/`sessionstorage`).
+
+> [!WARNING]
+> As Web Storage only works in browser environments it will fallback to a stub implementation on server environments.
+
+Example:
+
+```ts
+const SyncStore = signalStore(
+ withStorageSync({
+ key: 'synced', // key used when writing to/reading from storage
+ autoSync: false, // read from storage on init and write on state changes - `true` by default
+ select: (state: User) => Partial, // projection to keep specific slices in sync
+ parse: (stateString: string) => State, // custom parsing from storage - `JSON.parse` by default
+ stringify: (state: User) => string, // custom stringification - `JSON.stringify` by default
+ storage: () => sessionstorage, // factory to select storage to sync with
+ })
+);
+```
+
+```ts
+@Component(...)
+public class SyncedStoreComponent {
+ private syncStore = inject(SyncStore);
+
+ updateFromStorage(): void {
+ this.syncStore.readFromStorage(); // reads the stored item from storage and patches the state
+ }
+
+ updateStorage(): void {
+ this.syncStore.writeToStorage(); // writes the current state to storage
+ }
+
+ clearStorage(): void {
+ this.syncStore.clearStorage(); // clears the stored item in storage
+
## Redux Connector for the NgRx Signal Store `createReduxState()`
The Redux Connector turns any `signalStore()` into a Gobal State Management Slice following the Redux pattern.
diff --git a/libs/ngrx-toolkit/src/index.ts b/libs/ngrx-toolkit/src/index.ts
index 3ffd679..bd0a466 100644
--- a/libs/ngrx-toolkit/src/index.ts
+++ b/libs/ngrx-toolkit/src/index.ts
@@ -3,7 +3,7 @@ export * from './lib/with-redux';
export * from './lib/with-call-state';
export * from './lib/with-undo-redo';
-export * from './lib/with-data-service';
-
+export * from './lib/with-data-service'
+export { withStorageSync, SyncConfig } from './lib/with-storage-sync';
export * from './lib/redux-connector';
-export * from './lib/redux-connector/rxjs-interop';
+export * from './lib/redux-connector/rxjs-interop';
\ No newline at end of file
diff --git a/libs/ngrx-toolkit/src/lib/with-storage-sync.spec.ts b/libs/ngrx-toolkit/src/lib/with-storage-sync.spec.ts
new file mode 100644
index 0000000..abc7b1c
--- /dev/null
+++ b/libs/ngrx-toolkit/src/lib/with-storage-sync.spec.ts
@@ -0,0 +1,237 @@
+import { getState, patchState, signalStore, withState } from '@ngrx/signals';
+import { withStorageSync } from './with-storage-sync';
+import { TestBed } from '@angular/core/testing';
+
+interface StateObject {
+ foo: string;
+ age: number;
+}
+const initialState: StateObject = {
+ foo: 'bar',
+ age: 18,
+};
+const key = 'FooBar';
+
+describe('withStorageSync', () => {
+ beforeEach(() => {
+ // make sure to start with a clean storage
+ localStorage.removeItem(key);
+ });
+
+ it('adds methods for storage access to the store', () => {
+ TestBed.runInInjectionContext(() => {
+ const Store = signalStore(withStorageSync({ key }));
+ const store = new Store();
+
+ expect(Object.keys(store)).toEqual([
+ 'clearStorage',
+ 'readFromStorage',
+ 'writeToStorage',
+ ]);
+ });
+ });
+
+ it('offers manual sync using provided methods', () => {
+ TestBed.runInInjectionContext(() => {
+ // prefill storage
+ localStorage.setItem(
+ key,
+ JSON.stringify({
+ foo: 'baz',
+ age: 99,
+ } as StateObject)
+ );
+
+ const Store = signalStore(withStorageSync({ key, autoSync: false }));
+ const store = new Store();
+ expect(getState(store)).toEqual({});
+
+ store.readFromStorage();
+ expect(getState(store)).toEqual({
+ foo: 'baz',
+ age: 99,
+ });
+
+ patchState(store, { ...initialState });
+ TestBed.flushEffects();
+
+ let storeItem = JSON.parse(localStorage.getItem(key) || '{}');
+ expect(storeItem).toEqual({
+ foo: 'baz',
+ age: 99,
+ });
+
+ store.writeToStorage();
+ storeItem = JSON.parse(localStorage.getItem(key) || '{}');
+ expect(storeItem).toEqual({
+ ...initialState,
+ });
+
+ store.clearStorage();
+ storeItem = localStorage.getItem(key);
+ expect(storeItem).toEqual(null);
+ });
+ });
+
+ describe('autoSync', () => {
+ it('inits from storage and write to storage on changes when set to `true`', () => {
+ TestBed.runInInjectionContext(() => {
+ // prefill storage
+ localStorage.setItem(
+ key,
+ JSON.stringify({
+ foo: 'baz',
+ age: 99,
+ } as StateObject)
+ );
+
+ const Store = signalStore(withStorageSync(key));
+ const store = new Store();
+ expect(getState(store)).toEqual({
+ foo: 'baz',
+ age: 99,
+ });
+
+ patchState(store, { ...initialState });
+ TestBed.flushEffects();
+
+ expect(getState(store)).toEqual({
+ ...initialState,
+ });
+ const storeItem = JSON.parse(localStorage.getItem(key) || '{}');
+ expect(storeItem).toEqual({
+ ...initialState,
+ });
+ });
+ });
+
+ it('does not init from storage and does write to storage on changes when set to `false`', () => {
+ TestBed.runInInjectionContext(() => {
+ // prefill storage
+ localStorage.setItem(
+ key,
+ JSON.stringify({
+ foo: 'baz',
+ age: 99,
+ } as StateObject)
+ );
+
+ const Store = signalStore(withStorageSync({ key, autoSync: false }));
+ const store = new Store();
+ expect(getState(store)).toEqual({});
+
+ patchState(store, { ...initialState });
+ const storeItem = JSON.parse(localStorage.getItem(key) || '{}');
+ expect(storeItem).toEqual({
+ foo: 'baz',
+ age: 99,
+ });
+ });
+ });
+ });
+
+ describe('select', () => {
+ it('syncs the whole state by default', () => {
+ TestBed.runInInjectionContext(() => {
+ const Store = signalStore(withStorageSync(key));
+ const store = new Store();
+
+ patchState(store, { ...initialState });
+ TestBed.flushEffects();
+
+ const storeItem = JSON.parse(localStorage.getItem(key) || '{}');
+ expect(storeItem).toEqual({
+ ...initialState,
+ });
+ });
+ });
+
+ it('syncs selected slices when specified', () => {
+ TestBed.runInInjectionContext(() => {
+ const Store = signalStore(
+ withState(initialState),
+ withStorageSync({ key, select: ({ foo }) => ({ foo }) })
+ );
+ const store = new Store();
+
+ patchState(store, { foo: 'baz' });
+ TestBed.flushEffects();
+
+ const storeItem = JSON.parse(localStorage.getItem(key) || '{}');
+ expect(storeItem).toEqual({
+ foo: 'baz',
+ });
+ });
+ });
+ });
+
+ describe('parse/stringify', () => {
+ it('uses custom parsing/stringification when specified', () => {
+ const parse = (stateString: string) => {
+ const [foo, age] = stateString.split('_');
+ return {
+ foo,
+ age: +age,
+ };
+ };
+
+ TestBed.runInInjectionContext(() => {
+ const Store = signalStore(
+ withState(initialState),
+ withStorageSync({
+ key,
+ parse,
+ stringify: (state) => `${state.foo}_${state.age}`,
+ })
+ );
+ const store = new Store();
+
+ patchState(store, { foo: 'baz' });
+ TestBed.flushEffects();
+
+ const storeItem = parse(localStorage.getItem(key) || '');
+ expect(storeItem).toEqual({
+ ...initialState,
+ foo: 'baz',
+ });
+ });
+ });
+ });
+
+ describe('storage factory', () => {
+ it('uses specified storage', () => {
+ TestBed.runInInjectionContext(() => {
+ // prefill storage
+ sessionStorage.setItem(
+ key,
+ JSON.stringify({
+ foo: 'baz',
+ age: 99,
+ } as StateObject)
+ );
+
+ const Store = signalStore(
+ withStorageSync({ key, storage: () => sessionStorage })
+ );
+ const store = new Store();
+ expect(getState(store)).toEqual({
+ foo: 'baz',
+ age: 99,
+ });
+
+ patchState(store, { ...initialState });
+ TestBed.flushEffects();
+
+ expect(getState(store)).toEqual({
+ ...initialState,
+ });
+ const storeItem = JSON.parse(sessionStorage.getItem(key) || '{}');
+ expect(storeItem).toEqual({
+ ...initialState,
+ });
+
+ store.clearStorage();
+ });
+ });
+ });
+});
diff --git a/libs/ngrx-toolkit/src/lib/with-storage-sync.ts b/libs/ngrx-toolkit/src/lib/with-storage-sync.ts
new file mode 100644
index 0000000..cc775e7
--- /dev/null
+++ b/libs/ngrx-toolkit/src/lib/with-storage-sync.ts
@@ -0,0 +1,160 @@
+import { isPlatformServer } from '@angular/common';
+import { PLATFORM_ID, effect, inject } from '@angular/core';
+import {
+ SignalStoreFeature,
+ getState,
+ patchState,
+ signalStoreFeature,
+ withHooks,
+ withMethods,
+} from '@ngrx/signals';
+import { Emtpy } from './shared/empty';
+
+type SignalStoreFeatureInput = Pick<
+ Parameters[0],
+ 'signals' | 'methods'
+> & {
+ state: State;
+};
+
+const NOOP = () => {};
+
+type WithStorageSyncFeatureResult = {
+ state: Emtpy;
+ signals: Emtpy;
+ methods: {
+ clearStorage(): void;
+ readFromStorage(): void;
+ writeToStorage(): void;
+ };
+};
+
+const StorageSyncStub: Pick<
+ WithStorageSyncFeatureResult,
+ 'methods'
+>['methods'] = {
+ clearStorage: NOOP,
+ readFromStorage: NOOP,
+ writeToStorage: NOOP,
+};
+
+export type SyncConfig = {
+ /**
+ * The key which is used to access the storage.
+ */
+ key: string;
+ /**
+ * Flag indicating if the store should read from storage on init and write to storage on every state change.
+ *
+ * `true` by default
+ */
+ autoSync?: boolean;
+ /**
+ * Function to select that portion of the state which should be stored.
+ *
+ * Returns the whole state object by default
+ */
+ select?: (state: State) => Partial;
+ /**
+ * Function used to parse the state coming from storage.
+ *
+ * `JSON.parse()` by default
+ */
+ parse?: (stateString: string) => State;
+ /**
+ * Function used to tranform the state into a string representation.
+ *
+ * `JSON.stringify()` by default
+ */
+ stringify?: (state: State) => string;
+ /**
+ * Factory function used to select the storage.
+ *
+ * `localstorage` by default
+ */
+ storage?: () => Storage;
+};
+
+/**
+ * Enables store synchronization with storage.
+ *
+ * Only works on browser platform.
+ */
+export function withStorageSync<
+ State extends object,
+ Input extends SignalStoreFeatureInput
+>(key: string): SignalStoreFeature;
+export function withStorageSync<
+ State extends object,
+ Input extends SignalStoreFeatureInput
+>(
+ config: SyncConfig
+): SignalStoreFeature;
+export function withStorageSync<
+ State extends object,
+ Input extends SignalStoreFeatureInput
+>(
+ configOrKey: SyncConfig | string
+): SignalStoreFeature {
+ const {
+ key,
+ autoSync = true,
+ select = (state: State) => state,
+ parse = JSON.parse,
+ stringify = JSON.stringify,
+ storage: storageFactory = () => localStorage,
+ } = typeof configOrKey === 'string' ? { key: configOrKey } : configOrKey;
+
+ return signalStoreFeature(
+ withMethods((store, platformId = inject(PLATFORM_ID)) => {
+ if (isPlatformServer(platformId)) {
+ console.warn(
+ `'withStorageSync' provides non-functional implementation due to server-side execution`
+ );
+ return StorageSyncStub;
+ }
+
+ const storage = storageFactory();
+
+ return {
+ /**
+ * Removes the item stored in storage.
+ */
+ clearStorage(): void {
+ storage.removeItem(key);
+ },
+ /**
+ * Reads item from storage and patches the state.
+ */
+ readFromStorage(): void {
+ const stateString = storage.getItem(key);
+ if (stateString) {
+ patchState(store, parse(stateString));
+ }
+ },
+ /**
+ * Writes selected portion to storage.
+ */
+ writeToStorage(): void {
+ const slicedState = select(getState(store) as State);
+ storage.setItem(key, stringify(slicedState));
+ },
+ };
+ }),
+ withHooks({
+ onInit(store, platformId = inject(PLATFORM_ID)) {
+ if (isPlatformServer(platformId)) {
+ return;
+ }
+
+ if (autoSync) {
+ store.readFromStorage();
+
+ effect(() => {
+ store.writeToStorage();
+ });
+ }
+ },
+ })
+ );
+}