├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .node-version ├── .npmignore ├── .npmrc ├── .prettierrc.json ├── LICENSE ├── README.md ├── jest.config.js ├── package.json ├── release.config.js ├── setup-jest.ts ├── src ├── component-store │ ├── immer-component-store.ts │ ├── index.ts │ ├── ng-package.json │ ├── provide-immer-component-store.ts │ └── tests │ │ ├── immer-component-store.test.ts │ │ └── provide-immer-component-store.test.ts ├── index.ts ├── ng-package.json ├── package.json ├── schematics │ └── migrations │ │ └── migration.json ├── shared │ ├── index.ts │ └── ng-package.json ├── signals │ ├── index.ts │ ├── ng-package.json │ └── tests │ │ └── immer-patch-state.jest.ts └── store │ ├── index.ts │ ├── ng-package.json │ └── tests │ ├── create-immer-reducer.test.ts │ └── immer-on.test.ts ├── tsconfig.jest.json ├── tsconfig.json └── tsconfig.spec.json /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | - 'beta' 8 | pull_request: {} 9 | 10 | jobs: 11 | build_test_release: 12 | strategy: 13 | matrix: 14 | node-version: ${{ fromJSON((github.ref == 'refs/heads/main' || github.ref == 'refs/heads/beta') && '[20]' || '[18,20]') }} 15 | os: ${{ fromJSON((github.ref == 'refs/heads/main' || github.ref == 'refs/heads/beta') && '["ubuntu-latest"]' || '["ubuntu-latest", "windows-latest"]') }} 16 | runs-on: ${{ matrix.os }} 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: use Node.js ${{ matrix.node-version }} on ${{ matrix.os }} 21 | uses: actions/setup-node@v2 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | - name: install 25 | run: npm install 26 | - name: test 27 | run: npm run test 28 | - name: build 29 | run: npm run build 30 | - name: Release 31 | if: github.repository == 'timdeschryver/ngrx-immer' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/beta') 32 | run: npx semantic-release 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 36 | -------------------------------------------------------------------------------- /.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 | *.tgz 8 | 9 | # Only exists if Bazel was run 10 | /bazel-out 11 | /coverage 12 | storybook-static 13 | 14 | # dependencies 15 | /node_modules 16 | 17 | # profiling files 18 | chrome-profiler-events*.json 19 | speed-measure-plugin*.json 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | .history/* 37 | 38 | # misc 39 | /.sass-cache 40 | /connect.lock 41 | /coverage 42 | /libpeerconnection.log 43 | npm-debug.log 44 | yarn-error.log 45 | testem.log 46 | /typings 47 | package-lock.json 48 | yarn.lock 49 | 50 | # System Files 51 | .DS_Store 52 | Thumbs.db 53 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 20 -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /coverage 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | package-lock=false 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "useTabs": true, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Tim Deschryver 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ngrx-immer 2 | 3 | > Immer wrappers around NgRx methods to simplify mutating state 4 | 5 | 6 | * [ngrx-immer](#ngrx-immer) 7 | * [Installation](#installation) 8 | * [Functions](#functions) 9 | * [`createImmerReducer` (@ngrx/store)](#createimmerreducer-ngrxstore) 10 | * [`immerOn` (@ngrx/store)](#immeron-ngrxstore) 11 | * [`ImmerComponentStore` (@ngrx/component-store)](#immercomponentstore-ngrxcomponent-store) 12 | * [`immerPatchState` (@ngrx/signals)](#immerpatchstate-ngrxsignals) 13 | * [`immerReducer`](#immerreducer) 14 | * [FAQ](#faq) 15 | * [Resources](#resources) 16 | 17 | 18 | ## Installation 19 | 20 | ```bash 21 | npm install ngrx-immer 22 | ``` 23 | 24 | > Do not forget to install immer 25 | 26 | ## Functions 27 | 28 | ### `createImmerReducer` (@ngrx/store) 29 | 30 | Creates an NgRx reducer, but allows you to mutate state without having to use to spread operator. 31 | 32 | - Use it when you want to go Immer all the way 33 | - Caveat, you have to return the state with each `on` method 34 | 35 | ```ts 36 | import { createImmerReducer } from 'ngrx-immer/store'; 37 | 38 | const todoReducer = createImmerReducer( 39 | { todos: [] }, 40 | on(newTodo, (state, action) => { 41 | state.todos.push({ text: action.todo, completed: false }); 42 | return state; 43 | }), 44 | on(completeTodo, (state, action) => { 45 | state.todos[action.index].completed = true; 46 | return state; 47 | }), 48 | ); 49 | ``` 50 | 51 | ### `immerOn` (@ngrx/store) 52 | 53 | Creates an NgRx reducer, but allows you to mutate state without having to use to spread operator. 54 | 55 | - Use it when you want to go sprinkle a little bit of Immer for more complex cases 56 | 57 | ```ts 58 | import { immerOn } from 'ngrx-immer/store'; 59 | 60 | const todoReducer = createReducer( 61 | { todos: [] }, 62 | on(newTodo, (state, action) => { 63 | return { 64 | ...state, 65 | todos: [...state.todos, action.todo], 66 | }; 67 | }), 68 | immerOn(completeTodo, (state, action) => { 69 | state.todos[action.index].completed = true; 70 | }), 71 | ); 72 | ``` 73 | 74 | ### `ImmerComponentStore` (@ngrx/component-store) 75 | 76 | Wraps Immer around the Component Store `updater` and `setState` methods. 77 | 78 | ```ts 79 | import { ImmerComponentStore } from 'ngrx-immer/component-store'; 80 | 81 | @Injectable() 82 | export class MoviesStore extends ImmerComponentStore { 83 | constructor() { 84 | super({ movies: [] }); 85 | } 86 | 87 | readonly addMovie = this.updater((state, movie: Movie) => { 88 | state.movies.push(movie); 89 | }); 90 | } 91 | ``` 92 | 93 | ### `immerPatchState` (@ngrx/signals) 94 | 95 | > [!IMPORTANT] 96 | > Because `@ngrx/signals` is in developer preview, the `immerPatchState` function is also in developer preview. It is ready to try, but may change before becoming stable. 97 | 98 | Provides an Immer-version of the `patchState` function from the `@ngrx/signals` package. In addition to partial state objects and updaters that update the state immutably, it accepts updater functions that update the state in a mutable manner. Similar to `patchState`, the `immerPatchState` function can be used to update the state of both SignalStore and SignalState. 99 | 100 | ```ts 101 | const UserStore = signalStore( 102 | withState({ 103 | user: { firstName: 'Konrad', lastName: 'Schultz' }, 104 | address: { city: 'Vienna', zip: '1010' }, 105 | }), 106 | withMethods((store) => ({ 107 | setLastName(lastName: string): void { 108 | immerPatchState(store, (state) => { 109 | state.user.lastName = lastName; 110 | }); 111 | }, 112 | setCity(city: string): void { 113 | immerPatchState(store, (state) => { 114 | state.address.city = city; 115 | }); 116 | }, 117 | })) 118 | ); 119 | ``` 120 | 121 | Please note, that the updater function can only mutate a change without returning it or return an immutable 122 | state without mutable change. 123 | 124 | This one is going to throw a runtime error: 125 | 126 | ```ts 127 | // will throw because of both returning and mutable change 128 | immerPatchState(userStore, (state) => { 129 | state.name.lastname = 'Sanders'; // mutable change 130 | return state; // returning state 131 | }); 132 | ``` 133 | 134 | ### `immerReducer` 135 | 136 | Inspired by [Alex Okrushko](https://twitter.com/alexokrushko), `immerReducer` is a reducer method that uses the Immer `produce` method. 137 | This method is used by all the methods in `ngrx-immer` provides. 138 | 139 | ## FAQ 140 | 141 | - See the Immer docs, [Update patterns](https://immerjs.github.io/immer/docs/update-patterns), on how to mutate state 142 | 143 | ## Resources 144 | 145 | - [Immer docs](https://immerjs.github.io/immer/) 146 | - [NgRx docs](https://ngrx.io/docs/) 147 | 148 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('jest').Config} */ 2 | const config = { 3 | preset: 'jest-preset-angular', 4 | setupFilesAfterEnv: ['/setup-jest.ts'], 5 | testMatch: ['**/*.jest.ts'], 6 | globalSetup: 'jest-preset-angular/global-setup', 7 | modulePathIgnorePatterns: ['/src/package.json'], 8 | moduleNameMapper: { 9 | 'ngrx-immer/signals': '/src/signals', 10 | 'ngrx-immer': '/src', 11 | }, 12 | }; 13 | 14 | module.exports = config; 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ngrx-immer", 3 | "version": "0.0.0-development", 4 | "scripts": { 5 | "test": "uvu -r tsm -r tsconfig-paths/register -i jest.ts src tests && jest", 6 | "build": "ng-packagr -p src/ng-package.json", 7 | "postbuild": "cpy README.md LICENSE dist && cpy schematics ../../dist --parents --cwd=src" 8 | }, 9 | "devDependencies": { 10 | "@angular/compiler": "^19.0.0", 11 | "@angular/compiler-cli": "^19.0.0", 12 | "@angular/core": "^19.0.0", 13 | "@ngrx/component-store": "^19.0.0", 14 | "@ngrx/signals": "^19.0.0", 15 | "@ngrx/store": "^19.0.0", 16 | "@types/jest": "^29.5.12", 17 | "cpy-cli": "^5.0.0", 18 | "immer": "^10.0.3", 19 | "jest": "^29.7.0", 20 | "jest-preset-angular": "^14.4.2", 21 | "ng-packagr": "^19.0.0", 22 | "prettier": "^3.2.5", 23 | "rimraf": "^5.0.5", 24 | "rxjs": "~7.8.1", 25 | "ts-node": "^10.9.1", 26 | "tsconfig-paths": "^4.0.0", 27 | "tsm": "^2.3.0", 28 | "typescript": "5.6.2", 29 | "uvu": "^0.5.6" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | pkgRoot: 'dist', 3 | branches: ['main', { name: 'beta', prerelease: true }], 4 | }; 5 | -------------------------------------------------------------------------------- /setup-jest.ts: -------------------------------------------------------------------------------- 1 | import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; 2 | 3 | setupZoneTestEnv(); -------------------------------------------------------------------------------- /src/component-store/immer-component-store.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, Optional } from '@angular/core'; 2 | import { Observable, Subscription } from 'rxjs'; 3 | import { ComponentStore, INITIAL_STATE_TOKEN } from '@ngrx/component-store'; 4 | 5 | import { immerReducer } from 'ngrx-immer/shared'; 6 | import { produce } from 'immer'; 7 | 8 | /** 9 | * Immer wrapper around `ImmerComponentStore` to mutate state 10 | * with `updater` and `setState` 11 | */ 12 | @Injectable() 13 | export class ImmerComponentStore< 14 | State extends object 15 | > extends ComponentStore { 16 | constructor(@Optional() @Inject(INITIAL_STATE_TOKEN) defaultState?: State) { 17 | super(produce(defaultState, s => s)); 18 | } 19 | 20 | updater< 21 | ProvidedType = void, 22 | OriginType = ProvidedType, 23 | ValueType = OriginType, 24 | ReturnType = OriginType extends void 25 | ? () => void 26 | : (observableOrValue: ValueType | Observable) => Subscription 27 | >(updaterFn: (state: State, value: OriginType) => void | State): ReturnType { 28 | return super.updater(immerReducer(updaterFn)); 29 | } 30 | 31 | setState(stateOrUpdaterFn: State | ((state: State) => void | State)): void { 32 | super.setState(stateOrUpdaterFn as State | ((state: State) => State)); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/component-store/index.ts: -------------------------------------------------------------------------------- 1 | export { ImmerComponentStore } from './immer-component-store'; 2 | export { provideImmerComponentStore } from './provide-immer-component-store'; -------------------------------------------------------------------------------- /src/component-store/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "lib": { 3 | "entryFile": "index.ts" 4 | } 5 | } -------------------------------------------------------------------------------- /src/component-store/provide-immer-component-store.ts: -------------------------------------------------------------------------------- 1 | import { ComponentStore, provideComponentStore } from '@ngrx/component-store'; 2 | import { Provider, Type } from '@angular/core'; 3 | import { ImmerComponentStore } from '.'; 4 | 5 | /** 6 | * @description 7 | * Immer wrapper around `provideComponentStore()` in `@ngrx/component-store`. 8 | * @returns the ImmerComponentStore class registered as a provider 9 | */ 10 | export function provideImmerComponentStore(componentStoreClass: Type>): Provider[] { 11 | return provideComponentStore(componentStoreClass as Type>); 12 | } -------------------------------------------------------------------------------- /src/component-store/tests/immer-component-store.test.ts: -------------------------------------------------------------------------------- 1 | import '@angular/compiler'; 2 | import { test } from 'uvu'; 3 | import * as assert from 'uvu/assert'; 4 | 5 | import { skip, take } from 'rxjs/operators'; 6 | import { ImmerComponentStore } from 'ngrx-immer/component-store'; 7 | 8 | const initialState: { shows: string[] } = { 9 | shows: [], 10 | }; 11 | 12 | test('updater mutates state', () => { 13 | const cs = new ImmerComponentStore(initialState); 14 | const addShow = cs.updater((state, show: string) => { 15 | state.shows.push(show); 16 | }); 17 | 18 | let newState = {}; 19 | cs.state$.pipe(skip(1), take(1)).subscribe((state) => { 20 | newState = state; 21 | }); 22 | 23 | addShow('The queens gambit'); 24 | 25 | assert.equal(newState, { 26 | shows: ['The queens gambit'], 27 | }); 28 | assert.is.not(newState, initialState); 29 | }); 30 | 31 | test('setState callback mutates state', () => { 32 | const cs = new ImmerComponentStore(initialState); 33 | 34 | let newState = {}; 35 | cs.state$.pipe(skip(1), take(1)).subscribe((state) => { 36 | newState = state; 37 | }); 38 | 39 | cs.setState((state) => { 40 | state.shows = ['The queens gambit']; 41 | }); 42 | 43 | assert.equal(newState, { 44 | shows: ['The queens gambit'], 45 | }); 46 | assert.is.not(newState, initialState); 47 | }); 48 | 49 | test('setState value mutates state', () => { 50 | const cs = new ImmerComponentStore(initialState); 51 | 52 | let newState = {}; 53 | cs.state$.pipe(skip(1), take(1)).subscribe((state) => { 54 | newState = state; 55 | }); 56 | 57 | cs.setState({ 58 | shows: ['The queens gambit'], 59 | }); 60 | 61 | assert.equal(newState, { 62 | shows: ['The queens gambit'], 63 | }); 64 | assert.is.not(newState, initialState); 65 | }); 66 | 67 | test.run(); 68 | -------------------------------------------------------------------------------- /src/component-store/tests/provide-immer-component-store.test.ts: -------------------------------------------------------------------------------- 1 | import '@angular/compiler'; 2 | import { test } from 'uvu'; 3 | import * as assert from 'uvu/assert'; 4 | 5 | import { ImmerComponentStore, provideImmerComponentStore } from 'ngrx-immer/component-store'; 6 | import { provideComponentStore } from '@ngrx/component-store'; 7 | 8 | 9 | test('provideImmerComponentStore() equals provideComponentStore()', () => { 10 | const ngrxProviders = provideComponentStore(DummyImmerComponentStore as any); 11 | 12 | const ngrxImmerProviders = provideImmerComponentStore(DummyImmerComponentStore); 13 | 14 | // assert 15 | assert.instance(ngrxImmerProviders, Array); 16 | assert.is.not(ngrxImmerProviders.length, 0); 17 | assert.equal(ngrxImmerProviders.toString(), ngrxProviders.toString()); 18 | }); 19 | 20 | class DummyImmerComponentStore extends ImmerComponentStore<{}> { } 21 | 22 | test.run(); 23 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './shared'; 2 | -------------------------------------------------------------------------------- /src/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../dist", 4 | "lib": { 5 | "entryFile": "index.ts" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ngrx-immer", 3 | "version": "0.0.0-development", 4 | "description": "Immer wrappers around NgRx methods createReducer, on, and ComponentStore", 5 | "keywords": [ 6 | "NgRx", 7 | "Redux", 8 | "Angular", 9 | "Immer", 10 | "Local State", 11 | "Component State", 12 | "State management" 13 | ], 14 | "author": "Tim Deschryver", 15 | "license": "MIT", 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/timdeschryver/ngrx-immer.git" 19 | }, 20 | "bugs": { 21 | "url": "https://github.com/timdeschryver/ngrx-immer/issues" 22 | }, 23 | "homepage": "https://github.com/timdeschryver/ngrx-immer#readme", 24 | "private": false, 25 | "sideEffects": false, 26 | "ng-update": { 27 | "migrations": "./schematics/migrations/migration.json" 28 | }, 29 | "peerDependencies": { 30 | "immer": ">= 7.0.0", 31 | "@ngrx/component-store": ">= 18.0.0", 32 | "@ngrx/store": ">= 18.0.0", 33 | "@ngrx/signals": ">= 18.0.0" 34 | }, 35 | "peerDependenciesMeta": { 36 | "@ngrx/component-store": { 37 | "optional": true 38 | }, 39 | "@ngrx/store": { 40 | "optional": true 41 | }, 42 | "@ngrx/signals": { 43 | "optional": true 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/schematics/migrations/migration.json: -------------------------------------------------------------------------------- 1 | { 2 | "schematics": {} 3 | } 4 | -------------------------------------------------------------------------------- /src/shared/index.ts: -------------------------------------------------------------------------------- 1 | import { produce } from 'immer'; 2 | 3 | /** 4 | * Helper method that wraps a reducer with the Immer `produce` method 5 | * Kudos to Alex Okrushko {@link https://lookout.dev/rules/simple-immer-base-function-to-be-used-in-ngrx-store-or-componentstore-for-transforming-data-%22mutably%22} 6 | */ 7 | export function immerReducer( 8 | callback: (state: State, next: Next) => State | void, 9 | ) { 10 | return (state: State | undefined, next: Next) => { 11 | return produce(state, (draft: State) => callback(draft, next)) as State; 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /src/shared/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "lib": { 3 | "entryFile": "index.ts" 4 | } 5 | } -------------------------------------------------------------------------------- /src/signals/index.ts: -------------------------------------------------------------------------------- 1 | import { PartialStateUpdater, patchState, WritableStateSource, Prettify } from '@ngrx/signals'; 2 | import { immerReducer } from 'ngrx-immer'; 3 | 4 | export type ImmerStateUpdater = (state: State) => void; 5 | 6 | function toFullStateUpdater(updater: PartialStateUpdater | ImmerStateUpdater): (state: State) => State | void { 7 | return (state: State) => { 8 | const patchedState = updater(state); 9 | if (patchedState) { 10 | return ({ ...state, ...patchedState }); 11 | } 12 | return; 13 | }; 14 | } 15 | export function immerPatchState(state: WritableStateSource, ...updaters: Array> | PartialStateUpdater> | ImmerStateUpdater>>): void { 16 | const immerUpdaters = updaters.map(updater => { 17 | if (typeof updater === 'function') { 18 | return immerReducer(toFullStateUpdater(updater)) as unknown as PartialStateUpdater; 19 | } 20 | return updater; 21 | }); 22 | patchState(state, ...immerUpdaters); 23 | } 24 | -------------------------------------------------------------------------------- /src/signals/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "lib": { 3 | "entryFile": "index.ts" 4 | } 5 | } -------------------------------------------------------------------------------- /src/signals/tests/immer-patch-state.jest.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PartialStateUpdater, 3 | patchState, 4 | signalStore, 5 | withComputed, 6 | withMethods, 7 | withState, 8 | } from '@ngrx/signals'; 9 | import { immerPatchState } from 'ngrx-immer/signals'; 10 | import { computed, effect } from '@angular/core'; 11 | import { TestBed } from '@angular/core/testing'; 12 | 13 | describe('immerPatchState (unprotected)', () => { 14 | const UnprotectedUserState = signalStore( 15 | { protectedState: false }, 16 | withState({ 17 | id: 1, 18 | name: { firstname: 'Konrad', lastname: 'Schultz' }, 19 | address: { city: 'Vienna', zip: '1010' }, 20 | }), 21 | withComputed(({ name }) => ({ 22 | prettyName: computed(() => `${name.firstname()} ${name.lastname()}`), 23 | })), 24 | ); 25 | 26 | const setup = () => { 27 | return new UnprotectedUserState(); 28 | }; 29 | 30 | it('smoketest', () => { 31 | const userState = setup(); 32 | expect(userState.id()).toBe(1); 33 | }); 34 | 35 | it('is type-safe', () => { 36 | const userState = setup(); 37 | 38 | //@ts-expect-error number is not a property 39 | immerPatchState(userState, { number: 1 }); 40 | 41 | //@ts-expect-error number is not a property 42 | immerPatchState(userState, (state) => ({ number: 1 })); 43 | }); 44 | 45 | it('allows patching with object literal', () => { 46 | const userState = setup(); 47 | immerPatchState(userState, { 48 | name: { firstname: 'Lucy', lastname: 'Sanders' }, 49 | }); 50 | expect(userState.prettyName()).toBe('Lucy Sanders'); 51 | }); 52 | 53 | describe('update with return value', () => { 54 | it('works with the default patch function', () => { 55 | const userState = setup(); 56 | immerPatchState(userState, ({ name }) => ({ 57 | name: { firstname: name.firstname, lastname: 'Sanders' }, 58 | })); 59 | expect(userState.prettyName()).toBe('Konrad Sanders'); 60 | }); 61 | 62 | it('works with chained patch functions', () => { 63 | const userState = setup(); 64 | 65 | function updateNames< 66 | T extends { 67 | name: { firstname: string; lastname: string }; 68 | }, 69 | >(newState: T): PartialStateUpdater { 70 | return (state) => { 71 | return { 72 | ...state, 73 | ...newState 74 | }; 75 | }; 76 | } 77 | 78 | immerPatchState( 79 | userState, 80 | updateNames({ name: { firstname: 'Konrad', lastname: 'Sanders' } }), 81 | (state) => { 82 | state.id = 2; 83 | }, 84 | (state) => { 85 | state.address = { city: 'Updated', zip: '1234' }; 86 | }, 87 | ); 88 | 89 | expect(userState.prettyName()).toBe('Konrad Sanders'); 90 | expect(userState.id()).toBe(2); 91 | expect(userState.address()).toEqual({ city: 'Updated', zip: '1234' }); 92 | }); 93 | 94 | it('does not emit other signals', () => { 95 | TestBed.runInInjectionContext(() => { 96 | let effectCounter = 0; 97 | const userState = setup(); 98 | effect(() => { 99 | userState.id(); 100 | effectCounter++; 101 | }); 102 | TestBed.flushEffects(); 103 | 104 | expect(effectCounter).toBe(1); 105 | immerPatchState(userState, ({ name }) => ({ 106 | name: { firstname: name.firstname, lastname: 'Sanders' }, 107 | })); 108 | 109 | TestBed.flushEffects(); 110 | expect(effectCounter).toBe(1); 111 | }); 112 | }); 113 | 114 | it('throws if a mutated patched state is returned', () => { 115 | const userState = setup(); 116 | 117 | expect(() => 118 | immerPatchState(userState, (state) => { 119 | state.name.lastname = 'Sanders'; 120 | return state; 121 | }), 122 | ).toThrow( 123 | '[Immer] An immer producer returned a new value *and* modified its draft.', 124 | ); 125 | }); 126 | }); 127 | 128 | describe('update without returning a value', () => { 129 | it('allows a mutable update', () => { 130 | const userState = setup(); 131 | immerPatchState(userState, (state) => { 132 | state.name = { firstname: 'Lucy', lastname: 'Sanders' }; 133 | }); 134 | expect(userState.prettyName()).toBe('Lucy Sanders'); 135 | }); 136 | 137 | it('does not emit other signals', () => { 138 | TestBed.runInInjectionContext(() => { 139 | let effectCounter = 0; 140 | const userState = setup(); 141 | effect(() => { 142 | userState.id(); 143 | effectCounter++; 144 | }); 145 | TestBed.flushEffects(); 146 | 147 | expect(effectCounter).toBe(1); 148 | immerPatchState(userState, (state) => { 149 | state.name = { firstname: 'Lucy', lastname: 'Sanders' }; 150 | }); 151 | 152 | TestBed.flushEffects(); 153 | expect(effectCounter).toBe(1); 154 | }); 155 | }); 156 | }); 157 | 158 | it('checks the Signal notification on multiple updates', () => { 159 | TestBed.runInInjectionContext(() => { 160 | // setup effects 161 | let addressEffectCounter = 0; 162 | let nameEffectCounter = 0; 163 | let idEffectCounter = 0; 164 | const userState = setup(); 165 | effect(() => { 166 | userState.id(); 167 | idEffectCounter++; 168 | }); 169 | effect(() => { 170 | userState.address(); 171 | addressEffectCounter++; 172 | }); 173 | effect(() => { 174 | userState.name(); 175 | nameEffectCounter++; 176 | }); 177 | 178 | // first run 179 | TestBed.flushEffects(); 180 | expect(idEffectCounter).toBe(1); 181 | expect(addressEffectCounter).toBe(1); 182 | expect(nameEffectCounter).toBe(1); 183 | 184 | // change 185 | immerPatchState(userState, (state) => { 186 | state.name = { firstname: 'Lucy', lastname: 'Sanders' }; 187 | state.address.zip = '1020'; 188 | }); 189 | 190 | // second run 191 | TestBed.flushEffects(); 192 | expect(idEffectCounter).toBe(1); 193 | expect(addressEffectCounter).toBe(2); 194 | expect(nameEffectCounter).toBe(2); 195 | }); 196 | }); 197 | }); 198 | 199 | describe('immerPatchState (protected)', () => { 200 | const ProtectedUserState = signalStore( 201 | { protectedState: true }, 202 | withState({ 203 | id: 1, 204 | name: { firstname: 'Konrad', lastname: 'Schultz' }, 205 | address: { city: 'Vienna', zip: '1010' }, 206 | }), 207 | withComputed(({ name }) => ({ 208 | prettyName: computed(() => `${name.firstname()} ${name.lastname()}`), 209 | })), 210 | withMethods((store) => ({ 211 | setName: (name: {firstname:string, lastname:string}) => immerPatchState(store, { name }), 212 | incrementId: () => immerPatchState(store, state => { 213 | state.id++; 214 | }), 215 | })) 216 | ); 217 | 218 | const setup = () => { 219 | return new ProtectedUserState(); 220 | }; 221 | 222 | it('smoketest', () => { 223 | const userState = setup(); 224 | expect(userState.id()).toBe(1); 225 | }); 226 | 227 | it('state is protected and cannot be updated from the outside', () => { 228 | const userState = setup(); 229 | 230 | //@ts-expect-error state cannot be updated 231 | patchState(userState, (state) => ({ number: 1 })); 232 | }); 233 | 234 | it('allows patching protected state using withMethods', () => { 235 | const userState = setup(); 236 | 237 | userState.incrementId(); 238 | userState.setName({ firstname: 'Lucy', lastname: 'Sanders' }); 239 | 240 | expect(userState.prettyName()).toBe('Lucy Sanders'); 241 | expect(userState.id()).toBe(2); 242 | }); 243 | }); 244 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Action, 3 | ReducerTypes, 4 | ActionReducer, 5 | createReducer, 6 | ActionCreator, 7 | ActionType, 8 | on, 9 | } from '@ngrx/store'; 10 | import { Draft } from 'immer'; 11 | 12 | import { immerReducer } from 'ngrx-immer/shared'; 13 | 14 | /** 15 | * An immer reducer that allows a void return 16 | */ 17 | export interface ImmerOnReducer { 18 | (state: Draft, action: ActionType): void; 19 | } 20 | 21 | /** 22 | * Immer wrapper around `on` to mutate state 23 | */ 24 | export function immerOn( 25 | ...args: [...creators: Creators, reducer: ImmerOnReducer] 26 | ): ReducerTypes { 27 | const reducer = (args.pop() as Function) as ActionReducer; 28 | return (on as any)(...(args as ActionCreator[]), immerReducer(reducer)); 29 | } 30 | 31 | /** 32 | * Immer wrapper around `createReducer` to mutate state 33 | */ 34 | export function createImmerReducer( 35 | initialState: State, 36 | ...ons: ReducerTypes[] 37 | ): ActionReducer { 38 | const reducer = createReducer(initialState, ...ons); 39 | return function reduce(state: State = initialState, action: A) { 40 | return immerReducer(reducer)(state, action); 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /src/store/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "lib": { 3 | "entryFile": "index.ts" 4 | } 5 | } -------------------------------------------------------------------------------- /src/store/tests/create-immer-reducer.test.ts: -------------------------------------------------------------------------------- 1 | import '@angular/compiler'; 2 | import { test } from 'uvu'; 3 | import * as assert from 'uvu/assert'; 4 | 5 | import { createAction, on, props } from '@ngrx/store'; 6 | import { createImmerReducer, immerOn } from 'ngrx-immer/store'; 7 | 8 | const addItem = createAction('add item', props<{ item: string }>()); 9 | const deleteItem = createAction('delete item', props<{ index: number }>()); 10 | const addOtherItem = createAction('add other item', props<{ item: string }>()); 11 | 12 | const reducer = createImmerReducer<{ items: string[]; otherItems: string[] }>( 13 | { 14 | items: [], 15 | otherItems: [], 16 | }, 17 | on(addItem, (state, { item }) => { 18 | if (item === 'noop') return state; 19 | state.items.push(item); 20 | return state; 21 | }), 22 | on(deleteItem, (state, { index }) => { 23 | state.items.splice(index, 1); 24 | return state; 25 | }), 26 | // works with `immerOn` 27 | immerOn(addOtherItem, (state, { item }) => { 28 | state.otherItems.push(item); 29 | }), 30 | ); 31 | 32 | test('returns the same instance when not modified', () => { 33 | const initialState = { items: [], otherItems: [] }; 34 | const state = reducer(initialState, addItem({ item: 'noop' })); 35 | assert.is(state, initialState); 36 | }); 37 | 38 | test('returns a different instance when modified', () => { 39 | const initialState = { items: [], otherItems: [] }; 40 | const state = reducer(initialState, addItem({ item: 'item one' })); 41 | assert.is.not(state, initialState); 42 | }); 43 | 44 | test('only updates affected properties', () => { 45 | const initialState = { items: [], otherItems: [] }; 46 | const state = reducer(initialState, addItem({ item: 'item one' })); 47 | assert.is.not(state.items, initialState.items); 48 | assert.is(state.otherItems, initialState.otherItems); 49 | }); 50 | 51 | test('smoketest', () => { 52 | const initialState = { items: [], otherItems: [] }; 53 | const actions = [ 54 | addItem({ item: 'item one' }), 55 | addItem({ item: 'item two' }), 56 | addItem({ item: 'item three' }), 57 | addOtherItem({ item: 'other item one' }), 58 | deleteItem({ index: 1 }), 59 | ]; 60 | const state = actions.reduce(reducer, initialState); 61 | assert.equal(state, { 62 | items: ['item one', 'item three'], 63 | otherItems: ['other item one'], 64 | }); 65 | }); 66 | 67 | test.run(); 68 | -------------------------------------------------------------------------------- /src/store/tests/immer-on.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | 4 | import { createReducer, createAction, props } from '@ngrx/store'; 5 | import { immerOn } from 'ngrx-immer/store'; 6 | 7 | const addItem = createAction('add item', props<{ item: string }>()); 8 | const deleteItem = createAction('delete item', props<{ index: number }>()); 9 | const addOtherItem = createAction('add other item', props<{ item: string }>()); 10 | 11 | const reducer = createReducer<{ items: string[]; otherItems: string[] }>( 12 | { 13 | items: [], 14 | otherItems: [], 15 | }, 16 | immerOn(addItem, (state, { item }) => { 17 | if (item === 'noop') return; 18 | state.items.push(item); 19 | }), 20 | immerOn(deleteItem, (state, { index }) => { 21 | state.items.splice(index, 1); 22 | }), 23 | immerOn(addOtherItem, (state, { item }) => { 24 | state.otherItems.push(item); 25 | }), 26 | ); 27 | 28 | test('returns the same instance when not modified', () => { 29 | const initialState = { items: [], otherItems: [] }; 30 | const state = reducer(initialState, addItem({ item: 'noop' })); 31 | assert.is(state, initialState); 32 | }); 33 | 34 | test('returns a different instance when modified', () => { 35 | const initialState = { items: [], otherItems: [] }; 36 | const state = reducer(initialState, addItem({ item: 'item one' })); 37 | assert.is.not(state, initialState); 38 | }); 39 | 40 | test('only updates affected properties', () => { 41 | const initialState = { items: [], otherItems: [] }; 42 | const state = reducer(initialState, addItem({ item: 'item one' })); 43 | assert.is.not(state.items, initialState.items); 44 | assert.is(state.otherItems, initialState.otherItems); 45 | }); 46 | 47 | test('smoketest', () => { 48 | const initialState = { items: [], otherItems: [] }; 49 | const actions = [ 50 | addItem({ item: 'item one' }), 51 | addItem({ item: 'item two' }), 52 | addItem({ item: 'item three' }), 53 | addOtherItem({ item: 'other item one' }), 54 | deleteItem({ index: 1 }), 55 | ]; 56 | const state = actions.reduce(reducer, initialState); 57 | assert.equal(state, { 58 | items: ['item one', 'item three'], 59 | otherItems: ['other item one'], 60 | }); 61 | }); 62 | 63 | test.run(); 64 | -------------------------------------------------------------------------------- /tsconfig.jest.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": ["jest"] 7 | }, 8 | "include": ["**/tests/*.jest.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "commonjs", 5 | "baseUrl": ".", 6 | "rootDir": ".", 7 | "strict": true, 8 | "noImplicitThis": true, 9 | "alwaysStrict": true, 10 | "noUnusedLocals": true, 11 | "noUnusedParameters": true, 12 | "noImplicitReturns": true, 13 | "esModuleInterop": true, 14 | "experimentalDecorators": true, 15 | "emitDecoratorMetadata": true, 16 | "paths": { 17 | "ngrx-immer": ["./src"], 18 | "ngrx-immer/shared": ["./src/shared"], 19 | "ngrx-immer/component-store": ["./src/component-store"], 20 | "ngrx-immer/signals": ["./src/signals"], 21 | "ngrx-immer/store": ["./src/store"], 22 | } 23 | }, 24 | "exclude": ["node_modules"] 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec" 6 | }, 7 | "include": ["**/tests/*.test.ts"] 8 | } 9 | --------------------------------------------------------------------------------