) as T;
30 | });
31 |
32 | /**
33 | * Initializes the state with default values
34 | * @param state: The complete initial state
35 | */
36 | public initialize(state: T): void {
37 | const signals: Partial> = {};
38 | (Object.keys(state) as P[]).forEach((key) =>
39 | signals[key] = signal(state[key])
40 | );
41 | this.signals = signals as Signals;
42 | }
43 |
44 | /**
45 | * Selects a single piece of the state as a computed Signal and optionally maps it through a
46 | * mapping function
47 | * @param key: The key we want to use to extract a piece of state as a signal
48 | * @param mappingFunction: (Optional) The callback function that will map to the computed signal
49 | */
50 | public select(key: K): Signal;
51 | public select(
52 | key: K,
53 | mappingFunction: (state: T[K]) => P
54 | ): Signal;
55 | public select(
56 | key: K,
57 | mappingFunction?: (state: T[K]) => P
58 | ): Signal {
59 | return computed(() => {
60 | const state = this.throwOrReturnSignals()[key]() as T[K];
61 | return mappingFunction ? (mappingFunction(state) as P) : (state as T[K]);
62 | });
63 | }
64 |
65 | /**
66 | * Selects multiple pieces of the state as a computed Signal and optionally maps it to a new signal
67 | * @param keys: The keys we want to use to extract pieces of state as a signal
68 | * @param mappingFunction: (Optional) The callback function that will map to the computed signal
69 | */
70 | public selectMany(keys: (keyof T)[]): Signal>;
71 | public selectMany(
72 | keys: (keyof T)[],
73 | mappingFunction: (obj: SpecificKeysOfObj) => P
74 | ): Signal;
75 | public selectMany
(
76 | keys: (keyof T)[],
77 | mappingFunction?: (obj: SpecificKeysOfObj) => P
78 | ): Signal> {
79 | return computed(() => {
80 | const signals = this.throwOrReturnSignals();
81 | const state = keys.reduce((obj, key) => {
82 | obj[key] = signals[key]();
83 | return obj;
84 | }, {} as Partial>) as SpecificKeysOfObj;
85 | return mappingFunction ? (mappingFunction(state) as P) : (state as SpecificKeysOfObj);
86 | });
87 | }
88 |
89 | /**
90 | * This method is used to pick pieces of state from somewhere else
91 | * It will return an object that contains properties as signals.
92 | * Used best in combination with the connect method
93 | * @param keys: The keys that are related to the pieces of state we want to pick
94 | */
95 | public pick(
96 | keys: (keyof T)[]
97 | ): PickedState {
98 | const signals = this.throwOrReturnSignals();
99 | return keys.reduce((obj, key) => {
100 | obj[key] = signals[key];
101 | return obj;
102 | }, {} as Partial>) as PickedState;
103 | }
104 |
105 | /**
106 | * Connects a partial state object where every property is a Signal.
107 | * It will connect all these signals to the state
108 | * This will automatically feed the state whenever one of the signals changes
109 | * It will use an Angular effect to calculate it
110 | * @param partial: The partial object holding the signals where we want to listen to
111 | */
112 | public connect(partial: Partial<{ [P in keyof T]: Signal }>): void {
113 | this.throwOrReturnSignals();
114 | Object.keys(partial).forEach((key: keyof T) => {
115 | effect(
116 | () => {
117 | const v = partial[key] as Signal;
118 | this.patch({ [key]: v() } as Partial);
119 | },
120 | // This will update the state, so we need to allow signal writes
121 | { allowSignalWrites: true }
122 | );
123 | });
124 | }
125 |
126 | /**
127 | * Connects a partial state object where every property is an RxJS Observable
128 | * It will connect all these observables to the state and clean up automatically
129 | * For every key a trigger will be registered that can be called by using the
130 | * `trigger()` method. The trigger will retrigger the producer function of the Observable in question
131 | * @param object
132 | */
133 | public connectObservables(partial: Partial<{ [P in keyof T]: Observable }>): void {
134 | this.throwOrReturnSignals();
135 | Object.keys(partial).forEach((key: keyof T) => {
136 | this.triggers[key] ||= signal(0);
137 | const obs$ = partial[key] as Observable;
138 | toObservable(this.triggers[key] as WritableSignal)
139 | .pipe(
140 | startWith(),
141 | switchMap(() => obs$),
142 | takeUntilDestroyed(),
143 | )
144 | .subscribe((v: Partial[keyof Partial]) => {
145 | this.patch({ [key]: v } as Partial);
146 | });
147 | });
148 | }
149 |
150 | /**
151 | * Retriggers the producer function of the Observable that is connected to this key
152 | * This only works in combination with the `connectObservables()` method.
153 | * @param key
154 | */
155 | public trigger(key: keyof T): void {
156 | if (!this.triggers[key]) {
157 | throw new Error(
158 | 'There is no trigger registered for this key! You need to connect an observable. ' +
159 | 'Please use connectObservables to register the triggers',
160 | );
161 | }
162 | (this.triggers[key] as WritableSignal).update((v) => v + 1);
163 | }
164 |
165 | /**
166 | * Patches the state with a partial object.
167 | * This will loop through all the state signals and update
168 | * them one by one
169 | * @param partial: The partial state that needs to be updated
170 | */
171 | public patch(partial: Partial): void {
172 | const signals = this.throwOrReturnSignals();
173 | (Object.keys(partial) as P[]).forEach((key: P) => {
174 | signals[key].set(partial[key] as T[P]);
175 | });
176 | }
177 |
178 | /**
179 | * Returns the state as a snapshot
180 | * This will read through all the signals in an untracked manner
181 | */
182 | public get snapshot(): T {
183 | return untracked(() => this.state());
184 | }
185 |
186 | private throwOrReturnSignals(): Signals {
187 | if (!this.signals) {
188 | throw new Error(notInitializedError);
189 | }
190 | return this.signals as Signals;
191 | }
192 | }
193 |
--------------------------------------------------------------------------------
/projects/ngx-signal-state/src/lib/signal-state.spec.ts:
--------------------------------------------------------------------------------
1 | import { notInitializedError, SignalState } from './signal-state';
2 | import { Component, signal } from '@angular/core';
3 | import { TestBed } from '@angular/core/testing';
4 | import { BehaviorSubject, Subject, tap } from 'rxjs';
5 |
6 | type TestState = {
7 | firstName: string;
8 | lastName: string;
9 | };
10 | const initialState = {
11 | firstName: 'Brecht',
12 | lastName: 'Billiet',
13 | };
14 | const patchedState = {
15 | firstName: 'Brecht2',
16 | lastName: 'Billiet2',
17 | };
18 |
19 | @Component({
20 | template: '',
21 | })
22 | class MyComponent extends SignalState {
23 | public firstName = signal(initialState.firstName);
24 | public lastName = signal(initialState.lastName);
25 |
26 | public constructor() {
27 | super();
28 | this.initialize(initialState);
29 | this.connect({
30 | firstName: this.firstName,
31 | lastName: this.lastName,
32 | });
33 | }
34 | }
35 |
36 | @Component({
37 | template: '',
38 | })
39 | class WithObservablesComponent extends SignalState {
40 | public firstName$$ = new BehaviorSubject(patchedState.firstName);
41 | public lastName$$ = new BehaviorSubject(patchedState.lastName);
42 |
43 | public constructor() {
44 | super();
45 | this.initialize({ ...initialState, producerFirstName: 0 });
46 | this.connectObservables({
47 | firstName: this.firstName$$.pipe(tap(() => this.patch({ producerFirstName: this.snapshot.producerFirstName + 1 }))),
48 | lastName: this.lastName$$,
49 | });
50 | }
51 | }
52 |
53 | describe('signal state', () => {
54 | describe('on initialize()', () => {
55 | it('should initialize the state correctly', () => {
56 | const state = new SignalState();
57 |
58 | state.initialize(initialState);
59 | expect(state.snapshot).toEqual(initialState);
60 | expect(state.state()).toEqual(initialState);
61 | });
62 | });
63 |
64 | describe('on select()', () => {
65 | describe('when not initialized', () => {
66 | it('should throw an error', () => {
67 | const state = new SignalState();
68 | expect(() => {
69 | state.select('firstName')();
70 | }).toThrowError(notInitializedError);
71 | });
72 | });
73 | it('should select the correct piece of state', () => {
74 | const state = new SignalState();
75 | state.initialize(initialState);
76 | expect(state.select('firstName')()).toEqual(initialState.firstName);
77 | expect(state.select('lastName')()).toEqual(initialState.lastName);
78 | state.patch(patchedState);
79 | expect(state.select('firstName')()).toEqual(patchedState.firstName);
80 | expect(state.select('lastName')()).toEqual(patchedState.lastName);
81 | });
82 | });
83 | describe('on selectMany()', () => {
84 | describe('when not initialized', () => {
85 | it('should throw an error', () => {
86 | const state = new SignalState();
87 | expect(() => {
88 | state.selectMany(['firstName', 'lastName'])();
89 | }).toThrowError(notInitializedError);
90 | });
91 | });
92 | it('should select the correct pieces of state', () => {
93 | const state = new SignalState();
94 | state.initialize(initialState);
95 | expect(state.selectMany(['firstName', 'lastName'])()).toEqual(initialState);
96 | state.patch(patchedState);
97 | expect(state.selectMany(['firstName', 'lastName'])()).toEqual(patchedState);
98 | });
99 | });
100 | describe('on pick()', () => {
101 | describe('when not initialized', () => {
102 | it('should throw an error', () => {
103 | const state = new SignalState();
104 | expect(() => {
105 | state.pick(['firstName', 'lastName']);
106 | }).toThrowError(notInitializedError);
107 | });
108 | });
109 | it('should return an object with the correct pieces of state as signals', () => {
110 | const state = new SignalState();
111 | state.initialize(initialState);
112 | const picked = state.pick(['firstName', 'lastName']);
113 | expect(picked.firstName()).toEqual(initialState.firstName);
114 | expect(picked.lastName()).toEqual(initialState.lastName);
115 | state.patch(patchedState);
116 | expect(picked.firstName()).toEqual(patchedState.firstName);
117 | expect(picked.lastName()).toEqual(patchedState.lastName);
118 | });
119 | });
120 | describe('on connect()', () => {
121 | describe('when not initialized', () => {
122 | it('should throw an error', () => {
123 | const state = new SignalState();
124 | expect(() => {
125 | state.connect({
126 | firstName: signal('firstName'),
127 | lastName: signal('lastName'),
128 | });
129 | }).toThrowError(notInitializedError);
130 | });
131 | });
132 | it('should listen to the passed signals and patch the state', () => {
133 | TestBed.configureTestingModule({
134 | declarations: [MyComponent],
135 | }).compileComponents();
136 | const fixture = TestBed.createComponent(MyComponent);
137 | const component = fixture.componentRef.instance;
138 | fixture.detectChanges();
139 | expect(component.state().firstName).toEqual(component.firstName());
140 | expect(component.state().lastName).toEqual(component.lastName());
141 | component.firstName.set('Brecht3');
142 | component.lastName.set('Billiet3');
143 | fixture.detectChanges();
144 | expect(component.state().firstName).toEqual('Brecht3');
145 | expect(component.state().lastName).toEqual('Billiet3');
146 | });
147 | });
148 | describe('on patch()', () => {
149 | describe('when not initialized', () => {
150 | it('should throw an error', () => {
151 | const state = new SignalState();
152 | expect(() => {
153 | state.patch({ lastName: '', firstName: '' });
154 | }).toThrowError(notInitializedError);
155 | });
156 | });
157 | it('should patch the state', () => {
158 | const state = new SignalState();
159 | state.initialize(initialState);
160 | state.patch(patchedState);
161 | expect(state.snapshot).toEqual(patchedState);
162 | expect(state.state()).toEqual(patchedState);
163 | });
164 | });
165 | describe('on connectObservables()', () => {
166 | describe('when not initialized', () => {
167 | it('should throw an error', () => {
168 | const state = new SignalState();
169 | const lastName$$ = new Subject();
170 | const firstName$$ = new Subject();
171 | expect(() => {
172 | state.connectObservables({ lastName: lastName$$, firstName: firstName$$ });
173 | }).toThrowError(notInitializedError);
174 | });
175 | });
176 | it('should subscribe to the passed observables and pass the state', () => {
177 | TestBed.configureTestingModule({
178 | declarations: [WithObservablesComponent],
179 | }).compileComponents();
180 | const fixture = TestBed.createComponent(WithObservablesComponent);
181 | const component = fixture.componentRef.instance;
182 | fixture.detectChanges();
183 | expect(component.state().firstName).toEqual('Brecht2');
184 | expect(component.state().lastName).toEqual('Billiet2');
185 | component.lastName$$.next('Billiet3');
186 | component.firstName$$.next('Brecht3');
187 | fixture.detectChanges();
188 | expect(component.state().firstName).toEqual('Brecht3');
189 | expect(component.state().lastName).toEqual('Billiet3');
190 | });
191 | it('should re-execute the producer function when the trigger method is called', () => {
192 | TestBed.configureTestingModule({
193 | declarations: [WithObservablesComponent],
194 | }).compileComponents();
195 | const fixture = TestBed.createComponent(WithObservablesComponent);
196 | const component = fixture.componentRef.instance;
197 | fixture.detectChanges();
198 | expect(component.state().producerFirstName).toEqual(1);
199 | component.trigger('firstName');
200 | fixture.detectChanges();
201 | component.trigger('firstName');
202 | fixture.detectChanges();
203 | component.trigger('firstName');
204 | fixture.detectChanges();
205 | expect(component.state().producerFirstName).toEqual(4);
206 | });
207 | });
208 | });
209 |
--------------------------------------------------------------------------------
/projects/examples/src/backend/db.json:
--------------------------------------------------------------------------------
1 | {
2 | "products": [
3 | {
4 | "name": "Apple iPhone 14 256GB Midnight + Apple USB-C Charger 20",
5 | "price": 1690,
6 | "description": "With the Apple iPhone 14 256GB Midnight + Apple USB-C Charger 20W bundle, you can fast charge your new iPhone. The Apple iPhone 14 is a real all-rounder. With the improved standard and wide-angle lens, you can take even sharper photos than its predecessor, the Apple iPhone 13. In addition, the TrueDepth selfie camera has autofocus. This means it'll focus on your face faster. And the image remains sharp if you move during video calls, for example. Even when there's not a lot of light. Thanks to the powerful A15 Bionic chip and 4GB RAM, you can quickly edit all your photos and multitask any way you want. You can store your photos and apps on the 256GB storage. With the special Action Mode, all your videos remain stable when you record something while you move around a lot. On the 6.1-inch OLED screen, you can watch all your favorite movies and series in high quality. Want more screen space? Choose the iPhone 14 Plus.",
7 | "advice": "We advice you to try this product",
8 | "id": 1,
9 | "categoryId": 1,
10 | "quantity": 22
11 | },
12 | {
13 | "name": "Apple AirPods 2 with charging case",
14 | "price": 139,
15 | "description": "With Apple AirPods 2 with Charging Case, you can address Siri without touching your earbuds. This now works via voice commands, like with the iPhone. The earbuds turn on automatically when you put them in your ears and pause when you take them out. You can charge the charging case with the included Lightning to USB charging cable. With a full battery, you can use the AirPods to listen to music for 5 hours. When you add the battery of the charging case, you can listen to your favorite songs for 24 hours.",
16 | "advice": "",
17 | "categoryId": 3,
18 | "id": 2,
19 | "quantity": 7
20 | },
21 | {
22 | "name": "Bowers & Wilkins PX7 S2 Black",
23 | "price": 422,
24 | "description": "With the Bowers & Wilkins PX7 S2, you can enjoy high-end sound quality, even in the most noisy areas. These headphones have high-end noise canceling, which reduces the ambient noise a lot. Do you start a call with someone? The music pauses automatically when you take off the headphones. If you put the headphones back on your head, the music continues. In the Bowers & Wilkins Headphones app, you can customize the sound reproduction via an equalizer.",
25 | "advice": "",
26 | "id": 3,
27 | "categoryId": 3,
28 | "quantity": 40
29 | },
30 | {
31 | "name": "Samsung QLED 55Q80A",
32 | "price": 779,
33 | "description": "With the Samsung QLED 55Q80A (2021), you watch colorful and high-contrast images. This television has a high brightness and rich color representation thanks to the QLED screen. As a result, subtle color tones in images of a blue sky or cloud field are clearly visible as well. Combined with the Full Array Local Dimming, the QLED screen provides a strong contrast. That means there's a significant difference between the darkest parts of the image and the brightest parts, so shadows are truly dark and light objects stand out against a dark background. For example, a clear moon against a black sky. Thanks to Adaptive Picture, the TV adjusts the image to your viewing situation. Are you watching a dark movie during the day with the curtains open? ",
34 | "advice": "",
35 | "id": 5,
36 | "categoryId": 4,
37 | "quantity": 6
38 | },
39 | {
40 | "name": "Samsung Neo QLED 8K 75QN900B (2022) + Soundbar",
41 | "price": 7769,
42 | "description": "With the Samsung Neo QLED 8K 75QN900B (2022) and HW-Q990B Soundbar, you can create your own home cinema. Thanks to the 8K resolution, every detail is razor sharp. For example, you can easily distinguish people in the audience at a soccer match or the hair of a tiger in a nature documentary. The smart Neo AI Quantum Processor 8K upscales the image to the 8K resolution when you watch images in 4K. Neo QLED technology provides strong contrast and vivid colors. Thousands of LED lights are individually controlled, which makes dark areas of the screen truly dark and bright areas very bright. The quantum dots provide a bright color reproduction and a wide color gamut. So you see every subtle hue in for example a blue sky. Connect all your peripherals to the Slim One Connect Box with a sleek design.",
43 | "advice": "",
44 | "id": 6,
45 | "categoryId": 4,
46 | "quantity": 6
47 | },
48 | {
49 | "name": "OnePlus Nord 2T 256GB Gray 5G",
50 | "price": 469,
51 | "description": "The OnePlus Nord 2T 256GB Gray 5G is a powerful mid-range smartphone that's very fast with average use. For example, you can quickly switch between your apps like Instagram, Spotify, and YouTube. There are 3 cameras at the back which can take decent photos. With the wide-angle lens, you can fit tall buildings or your whole family on the photo. You can store your photos on the 256GB storage, together with all your apps, music, and movies. The 4500mAh battery lasts the whole day with average use. You'll also get a 80W fast charger. This allows you to fully charge the Nord 2T within half an hour. You'll never have to go out with an empty battery. On the 6.43-inch Full HD screen, you can see many details of your videos, movies, and series. This screen refreshes 90 times per second, so the movements look smooth when you scroll.",
52 | "advice": "",
53 | "categoryId": 1,
54 | "id": 7,
55 | "quantity": 3
56 | },
57 | {
58 | "name": "Apple iPhone SE 2022 64GB Black",
59 | "price": 559,
60 | "description": "The Apple iPhone SE 2022 64GB Black has a powerful A15 Bionic Chip. As a result, you can multitask without the device slowing down and you can effortlessly use the most demanding apps. You can also connect via 5G with this iPhone. That way, you have a fast and stable connection in busy places as well. You can only take advantage of this with a SIM card that has a 5G mobile data plan. Don't have one? The iPhone SE 2022 is also suitable for 4G. On the 64GB storage, you have limited space for your favorite apps and photos. Do you want more storage space? Choose the 128 or 256GB version. Thanks to Smart HDR 4 and Deep Fusion, you can take better photos with the 12-megapixel camera than with its predecessor. You unlock the device with your fingerprint via the Touch ID home button.",
61 | "advice": "",
62 | "categoryId": 1,
63 | "id": 11,
64 | "quantity": 3
65 | },
66 | {
67 | "name": "Logitech M330 Silent Wireless Mouse Black",
68 | "price": 29.99,
69 | "description": "You'll no longer be distracted by clicking sounds with the Logitech M330 Silent Wireless Mouse. This mouse has silent buttons with rubber switches that muffle the sound. A click will make 90% less noise compared to a standard mouse while maintaining the familiar clicking motion. Connect the USB nano receiver to your laptop or PC and immediately connect wirelessly to the mouse. Constantly changing the batteries is a thing of the past, because the mouse will work up to 24 months on a single battery. Aren't using the mouse? You don't have to switch it off; the M330 will automatically switch to sleep mode until you use it again.",
70 | "advice": "",
71 | "id": 12,
72 | "quantity": 3
73 | },
74 | {
75 | "name": "Apple iPhone 14 256GB Midnight + Apple USB-C Charger 20",
76 | "price": 1690,
77 | "description": "With the Apple iPhone 14 256GB Midnight + Apple USB-C Charger 20W bundle, you can fast charge your new iPhone. The Apple iPhone 14 is a real all-rounder. With the improved standard and wide-angle lens, you can take even sharper photos than its predecessor, the Apple iPhone 13. In addition, the TrueDepth selfie camera has autofocus. This means it'll focus on your face faster. And the image remains sharp if you move during video calls, for example. Even when there's not a lot of light. Thanks to the powerful A15 Bionic chip and 4GB RAM, you can quickly edit all your photos and multitask any way you want. You can store your photos and apps on the 256GB storage. With the special Action Mode, all your videos remain stable when you record something while you move around a lot. On the 6.1-inch OLED screen, you can watch all your favorite movies and series in high quality. Want more screen space? Choose the iPhone 14 Plus.",
78 | "advice": "We advice you to try this product",
79 | "id": 11,
80 | "categoryId": 1,
81 | "quantity": 22
82 | },
83 | {
84 | "name": "Apple AirPods 2 with charging case",
85 | "price": 139,
86 | "description": "With Apple AirPods 2 with Charging Case, you can address Siri without touching your earbuds. This now works via voice commands, like with the iPhone. The earbuds turn on automatically when you put them in your ears and pause when you take them out. You can charge the charging case with the included Lightning to USB charging cable. With a full battery, you can use the AirPods to listen to music for 5 hours. When you add the battery of the charging case, you can listen to your favorite songs for 24 hours.",
87 | "advice": "",
88 | "categoryId": 3,
89 | "id": 12,
90 | "quantity": 7
91 | },
92 | {
93 | "name": "Bowers & Wilkins PX7 S2 Black",
94 | "price": 422,
95 | "description": "With the Bowers & Wilkins PX7 S2, you can enjoy high-end sound quality, even in the most noisy areas. These headphones have high-end noise canceling, which reduces the ambient noise a lot. Do you start a call with someone? The music pauses automatically when you take off the headphones. If you put the headphones back on your head, the music continues. In the Bowers & Wilkins Headphones app, you can customize the sound reproduction via an equalizer.",
96 | "advice": "",
97 | "id": 13,
98 | "categoryId": 3,
99 | "quantity": 40
100 | },
101 | {
102 | "name": "Samsung QLED 55Q80A",
103 | "price": 779,
104 | "description": "With the Samsung QLED 55Q80A (2021), you watch colorful and high-contrast images. This television has a high brightness and rich color representation thanks to the QLED screen. As a result, subtle color tones in images of a blue sky or cloud field are clearly visible as well. Combined with the Full Array Local Dimming, the QLED screen provides a strong contrast. That means there's a significant difference between the darkest parts of the image and the brightest parts, so shadows are truly dark and light objects stand out against a dark background. For example, a clear moon against a black sky. Thanks to Adaptive Picture, the TV adjusts the image to your viewing situation. Are you watching a dark movie during the day with the curtains open? ",
105 | "advice": "",
106 | "id": 15,
107 | "categoryId": 4,
108 | "quantity": 6
109 | },
110 | {
111 | "name": "Samsung Neo QLED 8K 75QN900B (2022) + Soundbar",
112 | "price": 7769,
113 | "description": "With the Samsung Neo QLED 8K 75QN900B (2022) and HW-Q990B Soundbar, you can create your own home cinema. Thanks to the 8K resolution, every detail is razor sharp. For example, you can easily distinguish people in the audience at a soccer match or the hair of a tiger in a nature documentary. The smart Neo AI Quantum Processor 8K upscales the image to the 8K resolution when you watch images in 4K. Neo QLED technology provides strong contrast and vivid colors. Thousands of LED lights are individually controlled, which makes dark areas of the screen truly dark and bright areas very bright. The quantum dots provide a bright color reproduction and a wide color gamut. So you see every subtle hue in for example a blue sky. Connect all your peripherals to the Slim One Connect Box with a sleek design.",
114 | "advice": "",
115 | "id": 16,
116 | "categoryId": 4,
117 | "quantity": 6
118 | },
119 | {
120 | "name": "OnePlus Nord 2T 256GB Gray 5G",
121 | "price": 469,
122 | "description": "The OnePlus Nord 2T 256GB Gray 5G is a powerful mid-range smartphone that's very fast with average use. For example, you can quickly switch between your apps like Instagram, Spotify, and YouTube. There are 3 cameras at the back which can take decent photos. With the wide-angle lens, you can fit tall buildings or your whole family on the photo. You can store your photos on the 256GB storage, together with all your apps, music, and movies. The 4500mAh battery lasts the whole day with average use. You'll also get a 80W fast charger. This allows you to fully charge the Nord 2T within half an hour. You'll never have to go out with an empty battery. On the 6.43-inch Full HD screen, you can see many details of your videos, movies, and series. This screen refreshes 90 times per second, so the movements look smooth when you scroll.",
123 | "advice": "",
124 | "categoryId": 1,
125 | "id": 17,
126 | "quantity": 3
127 | },
128 | {
129 | "name": "Apple iPhone SE 2022 64GB Black",
130 | "price": 559,
131 | "description": "The Apple iPhone SE 2022 64GB Black has a powerful A15 Bionic Chip. As a result, you can multitask without the device slowing down and you can effortlessly use the most demanding apps. You can also connect via 5G with this iPhone. That way, you have a fast and stable connection in busy places as well. You can only take advantage of this with a SIM card that has a 5G mobile data plan. Don't have one? The iPhone SE 2022 is also suitable for 4G. On the 64GB storage, you have limited space for your favorite apps and photos. Do you want more storage space? Choose the 128 or 256GB version. Thanks to Smart HDR 4 and Deep Fusion, you can take better photos with the 12-megapixel camera than with its predecessor. You unlock the device with your fingerprint via the Touch ID home button.",
132 | "advice": "",
133 | "categoryId": 1,
134 | "id": 111,
135 | "quantity": 3
136 | },
137 | {
138 | "name": "Logitech M330 Silent Wireless Mouse Black",
139 | "price": 29.99,
140 | "description": "You'll no longer be distracted by clicking sounds with the Logitech M330 Silent Wireless Mouse. This mouse has silent buttons with rubber switches that muffle the sound. A click will make 90% less noise compared to a standard mouse while maintaining the familiar clicking motion. Connect the USB nano receiver to your laptop or PC and immediately connect wirelessly to the mouse. Constantly changing the batteries is a thing of the past, because the mouse will work up to 24 months on a single battery. Aren't using the mouse? You don't have to switch it off; the M330 will automatically switch to sleep mode until you use it again.",
141 | "advice": "",
142 | "id": 112,
143 | "quantity": 3
144 | }
145 | ],
146 | "categories": [
147 | {
148 | "name": "smartphones",
149 | "description": "Smartphones are used to make phone calls and send text messages but they can also be used for accessing the internet and check your emails, search the internet and much more. There are many different brands of smartphones e.g.dd",
150 | "id": 1
151 | },
152 | {
153 | "name": "Headphones",
154 | "description": "Headphones are a pair of small loudspeaker drivers worn on or around the head over a user's ears. They are electroacoustic transducers, which convert an electrical signal to a corresponding sound.",
155 | "id": 3
156 | },
157 | {
158 | "name": "Televisions",
159 | "description": "A television set (also known as a television receiver or televisor or simply a television, TV set, TV receiver or TV) is a machine with a screen or set of lenses. Televisions receive broadcasting signals and change them into pictures and sound.",
160 | "id": 4
161 | }
162 | ]
163 | }
164 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ngx-signal-state: Opinionated Microsized Simple State management for Angular Signals
2 |
3 | 
4 |
5 | | Principle | | Description |
6 | | -------------- | --- | ----------------------------------------------------------- |
7 | | Simple | Yes | Only a handful methods, no complex ngrx structures |
8 | | Small | Yes | Minified and compressed: 2KB |
9 | | Opinionated | Yes | Structured and opinionated way of state management |
10 | | No boilerplate | Yes | No selectors, reducers, actions, action types, effects, ... |
11 | | Easy to learn | Yes | Provides everything, but still very small |
12 | | Battle tested | Yes | Tested with big clients |
13 | | Type-safe | Yes | High focus on type-safety |
14 | | Examples | Yes | Working on tons of examples as we speak |
15 |
16 | ### Why not just use Signals?
17 |
18 | * [x] ngx-signal-state is more opinionated
19 | * [x] Advanced selecting logic, `select()`, `selectMany()`
20 | * [x] Forces us to treat components as state machines
21 | * [x] Clean api
22 | * [x] Because we can patch multiple signals in one command
23 | * [x] Connect functionality
24 | * [x] Plays well with Observables too
25 | * [x] Retrigger producer functions of connected observables
26 | * [x] Pick functionality of external states
27 | * [x] Easy snapshot
28 | * [x] State initialization in one place
29 |
30 | ## The principles
31 |
32 | This state management library has 2 important goals:
33 |
34 | * **Simplifying** state management: **KISS always!!**
35 | * **Opinionated** state management
36 |
37 | The principles are:
38 |
39 | * Every ui component is treated as a state machine
40 | * Every smart component is treated as a state machine
41 | * Features (Angular lazy loaded chunks) can have state machines shared for that feature
42 | * Application-wide there can be multiple global state machines
43 | * State machines can be provided on all levels of the injector tree
44 | * We can pick pieces of state from other state machines and add a one-way communication between them
45 |
46 | **The best practice here is to keep the state as low as possible.**
47 |
48 | ## Getting started
49 |
50 | ### Starting with ngx-signal-state
51 |
52 | We can start by installing `ngx-signal-state` with **npm** or **yarn**.
53 | After that we can import `SignalState` like this:
54 |
55 | ```typescript
56 | import { SignalState } from "ngx-signal-state";
57 | ```
58 |
59 | ### Creating a state machine for a component
60 |
61 | Creating a state machine for a component is simple. We just have to create a specific type
62 | for the state and extend our component from `SignalState` ;
63 |
64 | ```typescript
65 | type MyComponentState = {
66 | firstName: string;
67 | lastName: string;
68 | };
69 |
70 | export class MyComponent extends SignalState {
71 | }
72 | ```
73 |
74 | ### Initializing the state machine
75 |
76 | We can not consume `SignalState` functionality before we have initialized the state
77 | in the constructor with the `initialize()` method:
78 |
79 | ```typescript
80 | export class MyComponent extends SignalState {
81 | constructor(props) {
82 | super(props);
83 | this.initialize({
84 | firstName: 'Brecht',
85 | lastName: 'Billiet',
86 | });
87 | }
88 | }
89 | ```
90 |
91 | ### Getting the state as signals
92 |
93 | There are 3 ways to get the state as a signal.
94 |
95 | * `this.state` will return the state as a signal.
96 | * `this.select('propertyName')` will return a signal for the property that we provide.
97 | * `this.selectMany(['firstName', 'lastName'])`will return a signal with multiple pieces of state in it.
98 |
99 | ```typescript
100 | export class MyComponent extends SignalState {
101 | ...
102 | // Fetch the entire state as a signal
103 | state = this.state;
104 |
105 | // Only select one property and return it as a signal
106 | firstName = this.select('firstName');
107 |
108 | // Select multiple properties as a signal
109 | firstAndLastName = this.selectMany(['firstName', 'lastName'])
110 | }
111 | ```
112 |
113 | It's possible to add mapping functions as the second argument of the `select()` and `selectMany()` methods:
114 |
115 | ```typescript
116 | export class MyComponent extends SignalState {
117 | ...
118 | // Pass a mapping function
119 | firstName = this.select('firstName', firstname => firstname + '!!');
120 |
121 | // Pass a mapping function
122 | fullName = this.selectMany(['firstName', 'lastName'], ({ firstName, lastName }) => `${firstName} ${lastName}`)
123 | }
124 | ```
125 |
126 | ### Getting the state as a snapshot
127 |
128 | Sometimes we want an untracked snapshot. For that we can use the `snapshot` getter that will not
129 | keep track of its consumers.
130 |
131 | ```typescript
132 | export class MyComponent extends SignalState {
133 | ...
134 | protected save(): void {
135 | // Pick whatever we want from the snapshot of the state
136 | const { firstName, lastName } = this.snapshot;
137 | console.log(firstName, lastName);
138 | }
139 | }
140 | ```
141 |
142 | ### Patching state
143 |
144 | Setting multiple signals at the same time can be a drag. The `SignalState` offers a `patch()` method where we can pass a partial of the entire state.
145 |
146 | ```typescript
147 | export class MyComponent extends SignalState {
148 | ...
149 | protected userChange(user: User): void {
150 | this.patch({ firstName: user.firstName, lastName: user.lastName });
151 | }
152 | }
153 | ```
154 |
155 | ### Connecting signals to the state
156 |
157 | Sometimes we want to calculate pieces of state and connect those to our state machine.
158 | Any signal that we have can be connected to the state. Some examples are:
159 |
160 | * Pieces of other state machines (global state)
161 | * Signals that are provided by Angular (Input signals, query signals, ...)
162 | * Calculated pieces of signals that are calculated by the `selectMany()` method
163 |
164 | To connect signals to the state we can use the `connect()` method where we pass a partial object where
165 | every property is a signal. In the following example we can see that we have a state for a component that has products with
166 | client-side pagination and client-side filtering. We keep `products` as state that we will load from the backend, but have 2
167 | calculated pieces of state: `filteredProducts` and `pagedProducts` . `filteredProducts` is calculated based on the `products` and `query` .
168 | `pagedProducts` is calculated based on `filteredProducts` , `pageIndex` and `itemsPerPage` . It should be clear how pieces of state are
169 | being calculated based on other pieces of state. In the `connect()` method we can connect these signals and the state machine would
170 | get automatically updated:
171 |
172 | ```typescript
173 | export class MyComponent extends SignalState {
174 | constructor(props) {
175 | super(props);
176 | this.initialize({
177 | pageIndex: 0,
178 | itemsPerPage: 5,
179 | query: '',
180 | products: [],
181 | filteredProducts: [],
182 | pagedProducts: [],
183 | });
184 | // Calculate the filtered products and store them in a signal
185 | const filteredProducts = this.selectMany(['products', 'query'], ({ products, query }) => {
186 | return products.filter((p) => p.name.toLowerCase().indexOf(query.toLowerCase()) > -1);
187 | });
188 |
189 | // Calculate the paged products and store them in a signal
190 | const pagedProducts = this.selectMany(['filteredProducts', 'pageIndex', 'itemsPerPage'],
191 | ({
192 | filteredProducts,
193 | pageIndex,
194 | itemsPerPage
195 | }) => {
196 | const offsetStart = pageIndex * itemsPerPage;
197 | const offsetEnd = (pageIndex + 1) * itemsPerPage;
198 | return filteredProducts.slice(offsetStart, offsetEnd);
199 | });
200 | // Connect the calculated signals
201 | this.connect({
202 | filteredProducts,
203 | pagedProducts,
204 | });
205 | }
206 | }
207 | ```
208 |
209 | ### Connecting Observables to the state
210 |
211 | While it is handy to connect signals to the state, it is also handy to connect Observables to the state.
212 | These Observables can be derived from form `valueChanges` , `activatedRoute` or even `http` Observables.
213 | The `connectObservables()` method will do 4 things for us:
214 |
215 | * Subscribe to the observable and feed the results to the local state machine
216 | * Only execute the producer function once ==> no more multicasting issues
217 | * Clean up after itself ==> No memory leaks
218 | * Register a trigger that can be called later with the `trigger()` method to re-execute the producer function of the Observable
219 |
220 | ```typescript
221 | export class MyComponent extends SignalState {
222 | constructor(props) {
223 | super(props);
224 | ...
225 | this.connectObservables({
226 | // Only execute the call once
227 | products: this.productService.getProducts(),
228 | // Adds a timer
229 | time: interval(1000).pipe(map(() => new Date().getTime())),
230 | })
231 | }
232 | }
233 | ```
234 |
235 | ### Retriggering Observables
236 |
237 | Sometimes we want to re-execute the producer function of an observable that is connected to the state. The most recurring example is the
238 | execution of an ajax call. In this example we see how we can refetch users with the `trigger()` method.
239 |
240 | ```typescript
241 | export class MyComponent extends SignalState {
242 | constructor(props) {
243 | super(props);
244 | ...
245 | // Connect products and register a trigger behind the scenes
246 | this.connectObservables({
247 | // Only execute the call once
248 | products: this.productService.getProducts(),
249 | })
250 | }
251 |
252 | protected refreshProducts(): void {
253 | // Results in new a `this.productService.getProducts()` call
254 | this.trigger('products');
255 | }
256 | }
257 | ```
258 |
259 | ### Picking state
260 |
261 | Every component should be treated as a state machine. Every state class should be treated as a state machine.
262 | However, sometimes we want to pick state from other state machines. The principle of picking state is that we listen
263 | to that state in a one way communication. If we pick a state we will get notified of updates, but when we do changes to our
264 | local state it will not reflect in the state we are listening to:
265 |
266 | ```typescript
267 | export class AppComponent extends SignalState {
268 | private readonly shoppingCartState = inject(ShoppingCartSignalState)
269 | ...
270 |
271 | constructor() {
272 | super();
273 | this.initialize({
274 | // set initial values
275 | entries: this.shoppingCartState.snapshot.entries,
276 | paid: this.shoppingCartState.snapshot.paid
277 | });
278 | this.connect({
279 | // listen to pieces of state in the shoppingCartState and connect it to our local state
280 | ...this.shoppingCartState.pick(['entries', 'paid'])
281 | })
282 | }
283 | }
284 | ```
285 |
286 | ### Creating a state class
287 |
288 | We should treat all our components as state machines, but sometimes we also need to share state.
289 | For that we can create simple state classes. This is an example of a **shopping cart** state:
290 |
291 | ```typescript
292 | export type ShoppingCartState = {
293 | entries: ShoppingCartEntry[];
294 | };
295 |
296 | @Injectable({
297 | // Our provide anywhere in the injector tree
298 | providedIn: 'root',
299 | })
300 | export class ShoppingCartSignalState extends SignalState {
301 | constructor() {
302 | super();
303 | // initialize the state
304 | this.initialize({
305 | entries: [],
306 | });
307 | }
308 |
309 | public addToCart(entry: ShoppingCartEntry): void {
310 | // Update the state in an immutable way
311 | const entries = [...this.snapshot.entries, entry];
312 | this.patch({ entries });
313 | }
314 |
315 | public deleteFromCart(id: number): void {
316 | // Update the state in an immutable way
317 | const entries = this.snapshot.entries.filter((entry) => entry.productId !== id);
318 | this.patch({ entries });
319 | }
320 |
321 | public updateAmount(id: number, amount: number): void {
322 | // Update the state in an immutable way
323 | const entries = this.snapshot.entries.map((item) => (item.productId === id ? { ...item, amount } : item));
324 | this.patch({ entries });
325 | }
326 | }
327 | ```
328 |
329 | This state is provided in the root of the application, so it will be a singleton.
330 | However, we can also provide any signal state machine on all levels of the application by using the `providers` property:
331 |
332 | * root
333 | * feature
334 | * smart component
335 | * ui component
336 |
337 | ## Facade pattern
338 |
339 | When creating largescale applications, it's a good idea to abstract the feature libs from the rest of the application.
340 | In `projects/examples/src/app/products-with-facade/products-with-facade.component.ts`, we can find an example where all the
341 | data access logic and global state management is abstracted behind a facade.
342 | We should take those rules into account:
343 | - A global state machine should never be injected into a smart component directly
344 | - We never want to expose all the state
345 | - We don't want to patch global state directly in a smart component
346 | - The facade should expose data-access methods
347 | - The facade should not contain logic
348 | - Every feature lib should only contain one facade
349 |
350 | A slimmed down version looks like this:
351 | ```typescript
352 | export class ProductsWithFacadeComponent extends SignalState {
353 | private readonly productsFacade = inject(ProductsFacade)
354 | ...
355 |
356 | constructor() {
357 | super();
358 | this.initialize({
359 | ...
360 | entries: this.productsFacade.shoppingCartSnapshot.entries
361 | });
362 | this.connectObservables({
363 | products: this.productsFacade.getProducts(),
364 | categories: this.productsFacade.getCategories(),
365 | ...
366 | })
367 | this.connect({
368 | filteredProducts: this.filteredProducts,
369 | pagedProducts: this.pagedProducts,
370 | ...this.productsFacade.pickFromShoppingCartState(['entries'])
371 | })
372 | }
373 |
374 | ...
375 | protected addToCard(product: Product): void {
376 | this.productsFacade.addToCart({ productId: product.id, amount: 1 });
377 | }
378 | }
379 | ```
380 |
381 | Let's create the facade:
382 |
383 | ```typescript
384 | @Injectable({ providedIn: 'root' })
385 | export class ProductsFacade {
386 | private readonly productService = inject(ProductService);
387 | private readonly categoryService = inject(CategoryService);
388 | private readonly shoppingCartState = inject(ShoppingCartSignalState)
389 |
390 | public get shoppingCartSnapshot() {
391 | // For pragmatic reasons, expose the snapshot
392 | return this.shoppingCartState.snapshot;
393 | }
394 |
395 | public pickFromShoppingCartState(keys: (keyof ShoppingCartState)[]): PickedState {
396 | // For pragmatic reasons, expose the pick method
397 | return this.shoppingCartState.pick(keys);
398 | }
399 |
400 | public getProducts(): Observable {
401 | return this.productService.getProducts();
402 | }
403 |
404 | public getCategories(): Observable {
405 | return this.categoryService.getCategories();
406 | }
407 |
408 | // Don't expose the patch method
409 | public addToCart(entry: ShoppingCartEntry): void {
410 | this.shoppingCartState.addToCart(entry);
411 | }
412 | }
413 | ```
414 |
415 | The goal of the facade is abstracting away tools and keeping the smart component ignorant.
416 |
417 | ## Examples
418 |
419 | Examples of the use of this library can be found in `projects/examples` .
420 | To start the backend api run `npm run api` and to start the demo application run `npm start` .
421 |
422 | ## Angular Version Compatibility
423 | * 1.0.0 requires Angular ^16.0.0
424 |
425 | ## Collaborate
426 |
427 | Do you want to collaborate on this with me?
428 | Reach out at [brecht@simplified.courses](mailto://brecht@simplified.courses)!
429 |
--------------------------------------------------------------------------------