├── src ├── assets │ └── .gitkeep ├── app │ ├── app.component.scss │ ├── app.routes.ts │ ├── app.config.ts │ ├── app.component.html │ └── app.component.ts ├── favicon.ico ├── styles.scss ├── main.ts └── index.html ├── .npmrc ├── projects └── ngrx-signals-storage │ ├── src │ ├── public-api.ts │ └── lib │ │ ├── config.ts │ │ ├── with-storage.ts │ │ └── with-storage.spec.ts │ ├── ng-package.json │ ├── tsconfig.lib.prod.json │ ├── tsconfig.spec.json │ ├── tsconfig.lib.json │ └── package.json ├── .editorconfig ├── renovate.json ├── tsconfig.app.json ├── tsconfig.spec.json ├── .gitignore ├── package.json ├── LICENSE ├── tsconfig.json ├── .github └── workflows │ └── workflow.yml ├── angular.json └── README.md /src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/larscom/ngrx-signals-storage/HEAD/src/favicon.ico -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" 2 | save-exact=true 3 | package-lock=true 4 | legacy-peer-deps=true 5 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | -------------------------------------------------------------------------------- /src/app/app.routes.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | 3 | export const routes: Routes = []; 4 | -------------------------------------------------------------------------------- /projects/ngrx-signals-storage/src/public-api.ts: -------------------------------------------------------------------------------- 1 | export { type Config } from './lib/config' 2 | export { withStorage } from './lib/with-storage' 3 | -------------------------------------------------------------------------------- /projects/ngrx-signals-storage/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/ngrx-signals-storage", 4 | "lib": { 5 | "entryFile": "src/public-api.ts" 6 | } 7 | } -------------------------------------------------------------------------------- /src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationConfig } from '@angular/core' 2 | import { provideRouter } from '@angular/router' 3 | import { routes } from './app.routes' 4 | 5 | export const appConfig: ApplicationConfig = { 6 | providers: [provideRouter(routes)] 7 | } 8 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 |

Counter

2 |
{{ store.count() }}
3 | 4 |

Date

5 |
{{ store.date() }}
6 | 7 |

Test

8 |
{{ store.test() }}
9 | 10 |

Unique numbers

11 |
{{ uniqueNumbers }}
12 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { bootstrapApplication } from '@angular/platform-browser'; 2 | import { appConfig } from './app/app.config'; 3 | import { AppComponent } from './app/app.component'; 4 | 5 | bootstrapApplication(AppComponent, appConfig) 6 | .catch((err) => console.error(err)); 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | ":disableDependencyDashboard" 6 | ], 7 | "packageRules": [ 8 | { 9 | "description": "Automerge non-major updates", 10 | "matchUpdateTypes": ["minor", "patch"], 11 | "automerge": true 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | @larscom/ngrx-signals-storage 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /projects/ngrx-signals-storage/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "extends": "./tsconfig.lib.json", 5 | "compilerOptions": { 6 | "declarationMap": false 7 | }, 8 | "angularCompilerOptions": { 9 | "compilationMode": "partial" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "extends": "./tsconfig.json", 5 | "compilerOptions": { 6 | "outDir": "./out-tsc/app", 7 | "types": [] 8 | }, 9 | "include": [ 10 | "src/**/*.ts" 11 | ], 12 | "exclude": [ 13 | "src/**/*.spec.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "extends": "./tsconfig.json", 5 | "compilerOptions": { 6 | "outDir": "./out-tsc/spec", 7 | "types": [ 8 | "vitest/globals" 9 | ] 10 | }, 11 | "include": [ 12 | "src/**/*.d.ts", 13 | "src/**/*.spec.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /projects/ngrx-signals-storage/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "extends": "../../tsconfig.json", 5 | "compilerOptions": { 6 | "outDir": "../../out-tsc/spec", 7 | "types": [ 8 | "vitest/globals" 9 | ] 10 | }, 11 | "include": [ 12 | "src/**/*.d.ts", 13 | "src/**/*.spec.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /projects/ngrx-signals-storage/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "extends": "../../tsconfig.json", 5 | "compilerOptions": { 6 | "outDir": "../../out-tsc/lib", 7 | "declaration": true, 8 | "declarationMap": true, 9 | "inlineSources": true, 10 | "types": [] 11 | }, 12 | "include": [ 13 | "src/**/*.ts" 14 | ], 15 | "exclude": [ 16 | "**/*.spec.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ngrx-signals-storage", 3 | "version": "4.0.2", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "test": "ng test ngrx-signals-storage", 8 | "test:watch": "ng test ngrx-signals-storage --watch", 9 | "build": "ng build ngrx-signals-storage" 10 | }, 11 | "private": true, 12 | "dependencies": { 13 | "@angular/common": "^21.0.0", 14 | "@angular/compiler": "^21.0.0", 15 | "@angular/core": "^21.0.0", 16 | "@angular/forms": "^21.0.0", 17 | "@angular/platform-browser": "^21.0.0", 18 | "@angular/router": "^21.0.0", 19 | "@ngrx/signals": "21.0.0", 20 | "rxjs": "~7.8.0", 21 | "tslib": "^2.3.0" 22 | }, 23 | "devDependencies": { 24 | "@angular/build": "^21.0.0", 25 | "@angular/cli": "^21.0.0", 26 | "@angular/compiler-cli": "^21.0.0", 27 | "jsdom": "^27.2.0", 28 | "ng-packagr": "^21.0.0", 29 | "typescript": "~5.9.2", 30 | "vitest": "^4.0.8" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /projects/ngrx-signals-storage/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@larscom/ngrx-signals-storage", 3 | "version": "0.0.0", 4 | "description": "Save signal state (@ngrx/signals) to localstorage/sessionstorage and restore the state on page load (with SSR support).", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/larscom/ngrx-signals-storage.git" 8 | }, 9 | "publishConfig": { 10 | "access": "public" 11 | }, 12 | "keywords": [ 13 | "angular", 14 | "ngrx", 15 | "redux", 16 | "store", 17 | "state", 18 | "signals", 19 | "storage", 20 | "localstorage", 21 | "sessionstorage", 22 | "reactive" 23 | ], 24 | "author": "Lars Kniep", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/larscom/ngrx-signals-storage/issues" 28 | }, 29 | "homepage": "https://github.com/larscom/ngrx-signals-storage#readme", 30 | "peerDependencies": { 31 | "@angular/core": ">=17.0.0", 32 | "@ngrx/signals": ">=18.0.0" 33 | }, 34 | "dependencies": { 35 | "tslib": "^2.3.0" 36 | }, 37 | "sideEffects": false 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Lars Kniep 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /projects/ngrx-signals-storage/src/lib/config.ts: -------------------------------------------------------------------------------- 1 | export interface Config { 2 | /** 3 | * These keys will not get saved to storage 4 | */ 5 | excludeKeys: Array 6 | 7 | /** 8 | * Serializer for the state, by default it uses `JSON.stringify()` 9 | * @param state the last state known before it gets saved to storage 10 | */ 11 | serialize: (state: T) => string 12 | 13 | /** 14 | * Deserializer for the state, by default it uses `JSON.parse()` 15 | * @param state the last state known from the storage location 16 | */ 17 | deserialize: (state: string) => T 18 | 19 | /** 20 | * Save to storage will only occur when this function returns true 21 | * @param state the last state known before it gets saved to storage 22 | */ 23 | saveIf: (state: T) => boolean 24 | 25 | /** 26 | * Function that gets executed on a storage error (get/set) 27 | * @param error the error that occurred 28 | */ 29 | error: (error: any) => void 30 | } 31 | 32 | export const defaultConfig: Config = { 33 | excludeKeys: [], 34 | 35 | serialize: (state: any) => JSON.stringify(state), 36 | 37 | deserialize: (state: string) => JSON.parse(state), 38 | 39 | saveIf: (state: any) => true, 40 | 41 | error: (error: any) => console.error(error) 42 | } 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "strict": true, 6 | "noImplicitOverride": true, 7 | "noPropertyAccessFromIndexSignature": true, 8 | "noImplicitReturns": true, 9 | "paths": { 10 | "@larscom/ngrx-signals-storage": [ 11 | "./projects/ngrx-signals-storage/src/public-api" 12 | ] 13 | }, 14 | "noFallthroughCasesInSwitch": true, 15 | "skipLibCheck": true, 16 | "isolatedModules": true, 17 | "experimentalDecorators": true, 18 | "importHelpers": true, 19 | "target": "ES2022", 20 | "module": "preserve" 21 | }, 22 | "angularCompilerOptions": { 23 | "enableI18nLegacyMessageIdFormat": false, 24 | "strictInjectionParameters": true, 25 | "strictInputAccessModifiers": true, 26 | "strictTemplates": true, 27 | "disableTypeScriptVersionCheck": true 28 | }, 29 | "files": [], 30 | "references": [ 31 | { 32 | "path": "./tsconfig.app.json" 33 | }, 34 | { 35 | "path": "./tsconfig.spec.json" 36 | }, 37 | { 38 | "path": "./projects/ngrx-signals-storage/tsconfig.lib.json" 39 | }, 40 | { 41 | "path": "./projects/ngrx-signals-storage/tsconfig.spec.json" 42 | } 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Component, inject } from '@angular/core' 3 | import { withStorage } from '@larscom/ngrx-signals-storage' 4 | import { patchState, signalStore, withMethods, withState } from '@ngrx/signals' 5 | 6 | export const CounterStore = signalStore( 7 | withState({ 8 | count: 100, 9 | test: 5, 10 | date: new Date(), 11 | unique: new Set([1, 1, 3, 3]) 12 | }), 13 | withStorage('state', () => sessionStorage, { 14 | excludeKeys: ['test'], 15 | serialize: (state) => JSON.stringify({ ...state, unique: Array.from(state.unique) }), 16 | deserialize: (stateString) => { 17 | const state = JSON.parse(stateString) 18 | return { 19 | ...state, 20 | unique: new Set(state.unique) 21 | } 22 | } 23 | }), 24 | withMethods(({ count, ...store }) => ({ 25 | setDate(date: Date) { 26 | patchState(store, { date }) 27 | }, 28 | increment(by: number) { 29 | patchState(store, { count: count() + by }) 30 | } 31 | })) 32 | ) 33 | 34 | @Component({ 35 | selector: 'app-root', 36 | standalone: true, 37 | imports: [], 38 | providers: [CounterStore], 39 | templateUrl: './app.component.html', 40 | styleUrl: './app.component.scss' 41 | }) 42 | export class AppComponent { 43 | store = inject(CounterStore) 44 | 45 | constructor() { 46 | setTimeout(() => this.store.increment(100), 3000) 47 | } 48 | 49 | get uniqueNumbers() { 50 | return Array.from(this.store.unique()) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: workflow 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*.*.*' 7 | branches: 8 | - '**' 9 | merge_group: 10 | pull_request: 11 | branches: 12 | - main 13 | 14 | env: 15 | CI: true 16 | 17 | jobs: 18 | test: 19 | runs-on: ubuntu-latest 20 | env: 21 | TZ: Europe/Amsterdam 22 | steps: 23 | - uses: actions/checkout@v6 24 | - uses: actions/setup-node@v6 25 | with: 26 | node-version: 24 27 | registry-url: https://registry.npmjs.org/ 28 | - run: | 29 | npm ci --ignore-scripts --legacy-peer-deps 30 | npm run build 31 | npm run test 32 | 33 | publish: 34 | if: startsWith(github.ref, 'refs/tags/') 35 | needs: [test] 36 | runs-on: ubuntu-latest 37 | env: 38 | TZ: Europe/Amsterdam 39 | steps: 40 | - uses: actions/checkout@v6 41 | - uses: actions/setup-node@v6 42 | with: 43 | node-version: 24 44 | registry-url: https://registry.npmjs.org/ 45 | env: 46 | NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} 47 | - run: | 48 | npm ci --ignore-scripts --legacy-peer-deps 49 | npm run build 50 | - run: | 51 | cp README.md dist/ngrx-signals-storage 52 | cd dist/ngrx-signals-storage 53 | 54 | version=${{ github.ref_name }} 55 | sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$version\"/" ./package.json 56 | 57 | npm publish 58 | -------------------------------------------------------------------------------- /projects/ngrx-signals-storage/src/lib/with-storage.ts: -------------------------------------------------------------------------------- 1 | import { isPlatformServer } from '@angular/common' 2 | import { effect, inject, PLATFORM_ID } from '@angular/core' 3 | import { 4 | EmptyFeatureResult, 5 | getState, 6 | patchState, 7 | SignalStoreFeature, 8 | signalStoreFeature, 9 | SignalStoreFeatureResult, 10 | withHooks 11 | } from '@ngrx/signals' 12 | import { Config, defaultConfig } from './config' 13 | 14 | /** 15 | * The `withStorage` function that lets you save the state to localstorage/sessionstorage 16 | * and rehydrate the state upon page load. 17 | * 18 | * @param key the key under which the state should be saved into `Storage` 19 | * @param storage function that returns an implementation of the `Storage` interface, like: `sessionStorage` or `localStorage` 20 | * 21 | * @example 22 | * export const CounterStore = signalStore( 23 | * withState({ 24 | * count: 0 25 | * }), 26 | * withStorage('myKey', () => sessionStorage) 27 | * ) 28 | */ 29 | export function withStorage( 30 | key: string, 31 | storage: () => Storage, 32 | config?: Partial> 33 | ): SignalStoreFeature { 34 | return signalStoreFeature( 35 | withHooks({ 36 | onInit(store, platformId = inject(PLATFORM_ID)) { 37 | if (isPlatformServer(platformId)) { 38 | return 39 | } 40 | 41 | const cfg = { ...defaultConfig, ...config } 42 | const item = getFromStorage(key, storage(), cfg) 43 | const stateFromStorage: T['state'] | null = item ? cfg.deserialize(item) : null 44 | 45 | if (stateFromStorage != null) { 46 | const stateSignalKeys = Object.keys(store) 47 | const state = stateSignalKeys.reduce((state, key) => { 48 | const value = stateFromStorage[key as keyof T['state']] 49 | return value !== undefined 50 | ? { 51 | ...state, 52 | [key]: value 53 | } 54 | : state 55 | }, getState(store)) 56 | 57 | patchState(store, state) 58 | } 59 | 60 | effect(() => { 61 | const state = structuredClone(getState(store)) 62 | try { 63 | if (cfg.saveIf(state)) { 64 | cfg.excludeKeys.forEach((key) => { 65 | delete state[key as keyof object] 66 | }) 67 | storage().setItem(key, cfg.serialize(state)) 68 | } 69 | } catch (e) { 70 | cfg.error(e) 71 | } 72 | }) 73 | } 74 | }) 75 | ) 76 | } 77 | 78 | function getFromStorage( 79 | key: string, 80 | storage: Storage, 81 | cfg: Config 82 | ): string | null { 83 | try { 84 | return storage.getItem(key) 85 | } catch (e) { 86 | cfg.error(e) 87 | return null 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "ngrx-signals-storage-app": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "style": "scss" 11 | } 12 | }, 13 | "root": "", 14 | "sourceRoot": "src", 15 | "prefix": "app", 16 | "architect": { 17 | "build": { 18 | "builder": "@angular/build:application", 19 | "options": { 20 | "outputPath": "dist/ngrx-signals-storage-app", 21 | "index": "src/index.html", 22 | "browser": "src/main.ts", 23 | "polyfills": [], 24 | "tsConfig": "tsconfig.app.json", 25 | "inlineStyleLanguage": "scss", 26 | "assets": ["src/favicon.ico", "src/assets"], 27 | "styles": ["src/styles.scss"], 28 | "scripts": [] 29 | }, 30 | "configurations": { 31 | "production": { 32 | "budgets": [ 33 | { 34 | "type": "initial", 35 | "maximumWarning": "500kb", 36 | "maximumError": "1mb" 37 | }, 38 | { 39 | "type": "anyComponentStyle", 40 | "maximumWarning": "2kb", 41 | "maximumError": "4kb" 42 | } 43 | ], 44 | "outputHashing": "all" 45 | }, 46 | "development": { 47 | "optimization": false, 48 | "extractLicenses": false, 49 | "sourceMap": true 50 | } 51 | }, 52 | "defaultConfiguration": "production" 53 | }, 54 | "serve": { 55 | "builder": "@angular/build:dev-server", 56 | "configurations": { 57 | "production": { 58 | "buildTarget": "ngrx-signals-storage-app:build:production" 59 | }, 60 | "development": { 61 | "buildTarget": "ngrx-signals-storage-app:build:development" 62 | } 63 | }, 64 | "defaultConfiguration": "development" 65 | }, 66 | "extract-i18n": { 67 | "builder": "@angular/build:extract-i18n", 68 | "options": { 69 | "buildTarget": "ngrx-signals-storage-app:build" 70 | } 71 | }, 72 | "test": { 73 | "builder": "@angular/build:unit-test" 74 | } 75 | } 76 | }, 77 | "ngrx-signals-storage": { 78 | "projectType": "library", 79 | "root": "projects/ngrx-signals-storage", 80 | "sourceRoot": "projects/ngrx-signals-storage/src", 81 | "prefix": "lib", 82 | "architect": { 83 | "build": { 84 | "builder": "@angular/build:ng-packagr", 85 | "configurations": { 86 | "production": { 87 | "tsConfig": "projects/ngrx-signals-storage/tsconfig.lib.prod.json" 88 | }, 89 | "development": { 90 | "tsConfig": "projects/ngrx-signals-storage/tsconfig.lib.json" 91 | } 92 | }, 93 | "defaultConfiguration": "production" 94 | }, 95 | "test": { 96 | "builder": "@angular/build:unit-test", 97 | "options": { 98 | "tsConfig": "projects/ngrx-signals-storage/tsconfig.spec.json" 99 | } 100 | } 101 | } 102 | } 103 | }, 104 | "schematics": { 105 | "@schematics/angular:component": { 106 | "type": "component" 107 | }, 108 | "@schematics/angular:directive": { 109 | "type": "directive" 110 | }, 111 | "@schematics/angular:service": { 112 | "type": "service" 113 | }, 114 | "@schematics/angular:guard": { 115 | "typeSeparator": "." 116 | }, 117 | "@schematics/angular:interceptor": { 118 | "typeSeparator": "." 119 | }, 120 | "@schematics/angular:module": { 121 | "typeSeparator": "." 122 | }, 123 | "@schematics/angular:pipe": { 124 | "typeSeparator": "." 125 | }, 126 | "@schematics/angular:resolver": { 127 | "typeSeparator": "." 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @larscom/ngrx-signals-storage 2 | 3 | [![npm-version](https://img.shields.io/npm/v/@larscom/ngrx-signals-storage.svg?label=npm)](https://www.npmjs.com/package/@larscom/ngrx-signals-storage) 4 | ![npm](https://img.shields.io/npm/dw/@larscom/ngrx-signals-storage) 5 | [![license](https://img.shields.io/npm/l/@larscom/ngrx-signals-storage.svg)](https://github.com/larscom/ngrx-signals-storage/blob/main/LICENSE) 6 | 7 | > Save signal state (@ngrx/signals) to localStorage/sessionStorage and restore the state on page load with a single line of code (with SSR support). 8 | 9 | ## Installation 10 | 11 | ```bash 12 | npm install @larscom/ngrx-signals-storage 13 | ``` 14 | 15 | ## Dependencies 16 | 17 | `@larscom/ngrx-signals-storage` depends on [@ngrx/signals](https://ngrx.io/guide/signals/install) and [Angular](https://github.com/angular/angular) 18 | 19 | ## Usage 20 | 21 | Import the `withStorage` function and place it after the `withState` function. Optional configuration can be passed as 3th argument. 22 | 23 | ```ts 24 | import { withStorage } from '@larscom/ngrx-signals-storage' 25 | import { withState, signalStore } from '@ngrx/signals' 26 | 27 | export const CounterStore = signalStore( 28 | withState({ 29 | count: 0 30 | }), 31 | // state will be saved to sessionStorage under the key: 'myKey' 32 | withStorage('myKey', () => sessionStorage) 33 | ) 34 | ``` 35 | 36 | ## Configuration 37 | 38 | ```ts 39 | export interface Config { 40 | /** 41 | * These keys will not get saved to storage 42 | */ 43 | excludeKeys: Array 44 | 45 | /** 46 | * Serializer for the state, by default it uses `JSON.stringify()` 47 | * @param state the last state known before it gets saved to storage 48 | */ 49 | serialize: (state: T) => string 50 | 51 | /** 52 | * Deserializer for the state, by default it uses `JSON.parse()` 53 | * @param state the last state known from the storage location 54 | */ 55 | deserialize: (state: string) => T 56 | 57 | /** 58 | * Save to storage will only occur when this function returns true 59 | * @param state the last state known before it gets saved to storage 60 | */ 61 | saveIf: (state: T) => boolean 62 | 63 | /** 64 | * Function that gets executed on a storage error (get/set) 65 | * @param error the error that occurred 66 | */ 67 | error: (error: any) => void 68 | } 69 | ``` 70 | 71 | ## Save conditionally 72 | 73 | Sometimes you only want to save to storage on a specific condition. 74 | 75 | ```ts 76 | import { withStorage } from '@larscom/ngrx-signals-storage' 77 | import { withState, signalStore } from '@ngrx/signals' 78 | 79 | export const CounterStore = signalStore( 80 | withState({ 81 | count: 0 82 | }), 83 | // save only occurs when count is higher than 0 84 | withStorage('myKey', () => sessionStorage, { saveIf: ({ count }) => count > 0 }) 85 | ) 86 | ``` 87 | 88 | ## Skip properties 89 | 90 | Sometimes you want to ignore / exclude properties so they do not get saved into storage. On page reload, the initial value will be loaded instead. 91 | 92 | ```ts 93 | import { withStorage } from '@larscom/ngrx-signals-storage' 94 | import { withState, signalStore } from '@ngrx/signals' 95 | 96 | export const CounterStore = signalStore( 97 | withState({ 98 | count: 0, 99 | sum: 0 100 | }), 101 | // sum does not get saved into sessionStorage. 102 | withStorage('myKey', () => sessionStorage, { excludeKeys: ['sum'] }) 103 | ) 104 | ``` 105 | 106 | ## Common Errors 107 | 108 | Whenever you get errors this is most likely due to serialization / deserialization of the state. 109 | 110 | Objects like `Map` and `Set` are not serializable so you might need to implement your own serialize / deserialize function. 111 | 112 | ### Serialize / Deserialize 113 | 114 | Lets say you have a `Set` in your store, then you need a custom serialize / deserialize function to convert from `Set` to `Array` (serialize) and from `Array` to `Set` (deserialize) 115 | 116 | ```ts 117 | export const MyStore = signalStore( 118 | withState({ 119 | mySet: new Set([1, 1, 3, 3]) 120 | }), 121 | withStorage('myKey', () => sessionStorage, { 122 | serialize: (state) => JSON.stringify({ ...state, mySet: Array.from(state.mySet) }), 123 | deserialize: (stateString) => { 124 | const state = JSON.parse(stateString) 125 | return { 126 | ...state, 127 | mySet: new Set(state.mySet) 128 | } 129 | } 130 | }) 131 | ) 132 | ``` 133 | -------------------------------------------------------------------------------- /projects/ngrx-signals-storage/src/lib/with-storage.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing' 2 | import { signalStore, withState } from '@ngrx/signals' 3 | import { withStorage } from './with-storage' 4 | 5 | const storageKey = 'key' 6 | 7 | describe('withStorage', () => { 8 | it('should save to storage', () => { 9 | const storage = new TestStorage() 10 | const TestStore = signalStore( 11 | withState({ 12 | count: 0, 13 | todos: [ 14 | { 15 | name: 'todo 1', 16 | done: false, 17 | date: new Date('2023-01-01T01:00:00') 18 | }, 19 | { 20 | name: 'todo 2', 21 | done: true, 22 | date: new Date('2023-01-01T01:00:00') 23 | } 24 | ] 25 | }), 26 | withStorage(storageKey, () => storage) 27 | ) 28 | 29 | TestBed.configureTestingModule({ 30 | providers: [TestStore] 31 | }) 32 | 33 | const store = TestBed.inject(TestStore) 34 | expect(store.count()).toBe(0) 35 | expect(store.todos()).toHaveLength(2) 36 | 37 | // trigger effect() 38 | TestBed.tick() 39 | 40 | expect(storage.length).toEqual(1) 41 | expect(JSON.parse(storage.getItem(storageKey)!)).toEqual({ 42 | count: 0, 43 | todos: [ 44 | { 45 | name: 'todo 1', 46 | done: false, 47 | date: '2023-01-01T00:00:00.000Z' 48 | }, 49 | { 50 | name: 'todo 2', 51 | done: true, 52 | date: '2023-01-01T00:00:00.000Z' 53 | } 54 | ] 55 | }) 56 | }) 57 | 58 | it('should rehydrate from storage', () => { 59 | const storage = new TestStorage() 60 | storage.setItem( 61 | storageKey, 62 | JSON.stringify({ 63 | count: 100, 64 | todos: [ 65 | { 66 | name: 'todo 1', 67 | done: false, 68 | date: new Date('2023-01-01T01:00:00') 69 | }, 70 | { 71 | name: 'todo 2', 72 | done: true, 73 | date: new Date('2023-01-01T01:00:00') 74 | } 75 | ] 76 | }) 77 | ) 78 | expect(storage.length).toEqual(1) 79 | 80 | const TestStore = signalStore( 81 | withState({ 82 | count: 0, 83 | todos: [] 84 | }), 85 | withStorage(storageKey, () => storage) 86 | ) 87 | 88 | TestBed.configureTestingModule({ 89 | providers: [TestStore] 90 | }) 91 | 92 | const store = TestBed.inject(TestStore) 93 | expect(store.count()).toBe(100) 94 | expect(store.todos()).toHaveLength(2) 95 | 96 | // trigger effect() 97 | TestBed.tick() 98 | 99 | expect(storage.length).toEqual(1) 100 | expect(JSON.parse(storage.getItem(storageKey)!)).toEqual({ 101 | count: 100, 102 | todos: [ 103 | { 104 | name: 'todo 1', 105 | done: false, 106 | date: '2023-01-01T00:00:00.000Z' 107 | }, 108 | { 109 | name: 'todo 2', 110 | done: true, 111 | date: '2023-01-01T00:00:00.000Z' 112 | } 113 | ] 114 | }) 115 | }) 116 | 117 | it('should rehydrate boolean values from storage correctly', () => { 118 | const storage = new TestStorage() 119 | storage.setItem(storageKey, JSON.stringify({ playing: false })) 120 | 121 | const TestStore = signalStore( 122 | withState({ 123 | playing: true 124 | }), 125 | withStorage(storageKey, () => storage) 126 | ) 127 | 128 | TestBed.configureTestingModule({ 129 | providers: [TestStore] 130 | }) 131 | 132 | const store = TestBed.inject(TestStore) 133 | expect(store.playing()).toBe(false) 134 | }) 135 | 136 | it('should rehydrate array values from storage correctly', () => { 137 | const storage = new TestStorage() 138 | storage.setItem(storageKey, JSON.stringify({ items: [] })) 139 | 140 | const TestStore = signalStore( 141 | withState({ 142 | items: [1, 2, 3] 143 | }), 144 | withStorage(storageKey, () => storage) 145 | ) 146 | 147 | TestBed.configureTestingModule({ 148 | providers: [TestStore] 149 | }) 150 | 151 | const store = TestBed.inject(TestStore) 152 | expect(store.items()).toHaveLength(0) 153 | }) 154 | 155 | it('should ignore properties from storage if not exist on state', () => { 156 | const storage = new TestStorage() 157 | storage.setItem(storageKey, JSON.stringify({ count: 100, a: true, b: 0 })) 158 | 159 | const TestStore = signalStore( 160 | withState({ 161 | count: 0 162 | }), 163 | withStorage(storageKey, () => storage) 164 | ) 165 | 166 | TestBed.configureTestingModule({ 167 | providers: [TestStore] 168 | }) 169 | 170 | const store = TestBed.inject(TestStore) 171 | expect(store.count()).toBe(100) 172 | 173 | // trigger effect() 174 | TestBed.tick() 175 | 176 | expect(storage.length).toEqual(1) 177 | expect(JSON.parse(storage.getItem(storageKey)!)).toEqual({ count: 100 }) 178 | }) 179 | 180 | it('should not save to storage when saveIf returns false', () => { 181 | const storage = new TestStorage() 182 | 183 | const TestStore = signalStore( 184 | withState({ 185 | count: 0 186 | }), 187 | withStorage(storageKey, () => storage, { saveIf: ({ count }) => count > 0 }) 188 | ) 189 | 190 | TestBed.configureTestingModule({ 191 | providers: [TestStore] 192 | }) 193 | 194 | const store = TestBed.inject(TestStore) 195 | expect(store.count()).toBe(0) 196 | 197 | // trigger effect() 198 | TestBed.tick() 199 | 200 | expect(storage.length).toEqual(0) 201 | }) 202 | 203 | it('should call provided serialize func', () => { 204 | const storage = new TestStorage() 205 | 206 | const serialize = vitest.fn() 207 | const TestStore = signalStore( 208 | withState({ 209 | count: 0 210 | }), 211 | withStorage(storageKey, () => storage, { serialize }) 212 | ) 213 | 214 | TestBed.configureTestingModule({ 215 | providers: [TestStore] 216 | }) 217 | 218 | const store = TestBed.inject(TestStore) 219 | expect(store.count()).toBe(0) 220 | 221 | // trigger effect() 222 | TestBed.tick() 223 | 224 | expect(serialize).toHaveBeenCalledWith({ count: 0 }) 225 | }) 226 | 227 | it('should call provided deserialize func', () => { 228 | const storage = new TestStorage() 229 | storage.setItem(storageKey, JSON.stringify({ count: 100 })) 230 | 231 | const deserialize = vitest.fn() 232 | const TestStore = signalStore( 233 | withState({ 234 | count: 0 235 | }), 236 | withStorage(storageKey, () => storage, { deserialize }) 237 | ) 238 | 239 | TestBed.configureTestingModule({ 240 | providers: [TestStore] 241 | }) 242 | 243 | const store = TestBed.inject(TestStore) 244 | expect(store).toBeDefined() 245 | 246 | expect(deserialize).toHaveBeenCalledWith(JSON.stringify({ count: 100 })) 247 | }) 248 | 249 | it('should call provided error func on getItem', () => { 250 | const storage = new TestStorage() 251 | 252 | vitest.spyOn(storage, 'getItem').mockImplementation(() => { 253 | throw Error('storage error') 254 | }) 255 | 256 | const error = vitest.fn() 257 | const TestStore = signalStore( 258 | withState({ 259 | count: 0 260 | }), 261 | withStorage(storageKey, () => storage, { error }) 262 | ) 263 | 264 | TestBed.configureTestingModule({ 265 | providers: [TestStore] 266 | }) 267 | 268 | const store = TestBed.inject(TestStore) 269 | expect(store).toBeDefined() 270 | 271 | expect(error).toHaveBeenCalledWith(Error('storage error')) 272 | }) 273 | 274 | it('should call provided error func on setItem', () => { 275 | const storage = new TestStorage() 276 | 277 | vitest.spyOn(storage, 'setItem').mockImplementation(() => { 278 | throw Error('storage error') 279 | }) 280 | 281 | const error = vitest.fn() 282 | const TestStore = signalStore( 283 | withState({ 284 | count: 0 285 | }), 286 | withStorage(storageKey, () => storage, { error }) 287 | ) 288 | 289 | TestBed.configureTestingModule({ 290 | providers: [TestStore] 291 | }) 292 | 293 | const store = TestBed.inject(TestStore) 294 | expect(store.count()).toBe(0) 295 | 296 | // trigger effect() 297 | TestBed.tick() 298 | 299 | expect(error).toHaveBeenCalledWith(Error('storage error')) 300 | }) 301 | 302 | it('should exclude keys from state when saving to storage', () => { 303 | const storage = new TestStorage() 304 | 305 | const TestStore = signalStore( 306 | withState({ 307 | count: 100, 308 | excludeMe: 1, 309 | skipMe: 'test' 310 | }), 311 | withStorage(storageKey, () => storage, { excludeKeys: ['excludeMe', 'skipMe'] }) 312 | ) 313 | 314 | TestBed.configureTestingModule({ 315 | providers: [TestStore] 316 | }) 317 | 318 | const store = TestBed.inject(TestStore) 319 | expect(store.count()).toBe(100) 320 | expect(store.excludeMe()).toBe(1) 321 | expect(store.skipMe()).toBe('test') 322 | 323 | // trigger effect() 324 | TestBed.tick() 325 | 326 | expect(storage.length).toEqual(1) 327 | expect(JSON.parse(storage.getItem(storageKey)!)).toEqual({ count: 100 }) 328 | }) 329 | }) 330 | 331 | class TestStorage implements Storage { 332 | data = new Map() 333 | 334 | get length(): number { 335 | return this.data.size 336 | } 337 | 338 | clear(): void { 339 | this.data.clear() 340 | } 341 | 342 | getItem(key: string): string | null { 343 | return this.data.get(key) 344 | } 345 | 346 | key(index: number): string { 347 | return Array.from(this.data.keys())[index] 348 | } 349 | 350 | removeItem(key: string): void { 351 | this.data.delete(key) 352 | } 353 | 354 | setItem(key: string, value: string): void { 355 | this.data.set(key, value) 356 | } 357 | } 358 | --------------------------------------------------------------------------------