Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit d68a6f4

Browse files
authored
Merge branch 'main' into intro-action-fn
2 parents 4b55985 + 27f4464 commit d68a6f4

File tree

11 files changed

+1255
-269
lines changed

11 files changed

+1255
-269
lines changed

apps/docs/docs/state/effects/effects.mdx

Lines changed: 688 additions & 204 deletions
Large diffs are not rendered by default.
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
---
2+
sidebar_position: 1
3+
sidebar_label: 'Case Study: Refactor to rxEffects'
4+
title: 'Case Study: Refactor to rxEffects'
5+
---
6+
7+
## Problem
8+
9+
Let's get the problem it solves into code so we can refactor it.
10+
11+
We start with the side effect and 2 ways to execute it:
12+
13+
```typescript
14+
@Component({
15+
// ...
16+
})
17+
export class MyComponent {
18+
// The side effect (`console.log`)
19+
private effectFn = (num: number) => console.log('number: ' + num);
20+
// The interval triggers our function including the side effect
21+
private trigger$ = interval(1000);
22+
23+
constructor() {
24+
// [#1st approach] The subscribe's next callback it used to wrap and execute the side effect
25+
this.trigger$.subscribe(this.effectFn);
26+
27+
// [#2nd approach] `tap` is used to wrap and execute the side effect
28+
this.trigger$.pipe(tap(this.effectFn)).subscribe();
29+
}
30+
}
31+
```
32+
33+
As we introduced a memory leak we have to setup some boilerplate code to handle the cleanup logic:
34+
35+
```ts
36+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
37+
38+
@Component({
39+
// ...
40+
})
41+
export class MyComponent {
42+
// The side effect (`console.log`)
43+
private effectFn = (num: number) => console.log('number: ' + num);
44+
// The interval triggers our function including the side effect
45+
private trigger$ = interval(1000);
46+
47+
constructor() {
48+
this.trigger$.effect$
49+
.pipe(
50+
takeUntilDestroyed()
51+
// ⚠ Notice: Don't put any operator after takeUntil to avoid potential subscription leaks
52+
)
53+
.subscribe(this.effectFn);
54+
}
55+
}
56+
```
57+
58+
There are already a couple of things that are crucial:
59+
60+
- unsubscribe on destroy
61+
- having the `takeUntil` operator as last operator in the chain
62+
63+
Another way would be using the `subscription` to run the cleanup logic.
64+
65+
```ts
66+
@Component({
67+
// ...
68+
})
69+
export class MyComponent implements OnDestroy {
70+
// The side effect (`console.log`)
71+
private effectFn = (num: number) => console.log('number: ' + num);
72+
// The interval triggers our function including the side effect
73+
private trigger$ = interval(1000);
74+
// ⚠ Notice: The created subscription must be stored to `unsubscribe` later
75+
private readonly subscription: Subscription;
76+
77+
constructor() {
78+
// ⚠ Notice: Never forget to store the subscription
79+
this.subscription = this.trigger$.subscribe(this.effectFn);
80+
}
81+
82+
ngOnDestroy(): void {
83+
// ⚠ Notice: Never forget to cleanup the subscription
84+
this.subscription.unsubscribe();
85+
}
86+
}
87+
```
88+
89+
## Solution
90+
91+
In RxAngular we think the essential problem here is the call to `subscribe` itself. All `Subscription`s need to get unsubscribed manually which most of the time produces heavy boilerplate or even memory leaks if ignored or did wrong.
92+
Like `rxState`, `rxEffects` is a local instance created by a component and thus tied to the components life cycle.
93+
We can manage `Observables` as reactive triggers for side effects or manage `Subscription`s which internally hold side effects.
94+
To also provide an imperative way for developers to unsubscribe from the side effect `register` returns an "asyncId" similar to `setTimeout`.
95+
This can be used later on to call `unregister` and pass the async id retrieved from a previous `register` call. This stops and cleans up the side effect when invoked.
96+
97+
As an automatism any registered side effect will get cleaned up when the related component is destroyed.
98+
99+
Using `rxEffects` to maintain side-effects
100+
101+
```ts
102+
import { rxEffects } from '@rx-angular/state/effects';
103+
104+
@Component({})
105+
export class MyComponent {
106+
// The side effect (`console.log`)
107+
private effectFn = (num: number) => console.log('number: ' + num);
108+
// The interval triggers our function including the side effect
109+
private trigger$ = interval(1000);
110+
111+
private effects = rxEffects(({ register }) => {
112+
register(this.trigger$, this.effectFn);
113+
});
114+
}
115+
```
116+
117+
> **⚠ Notice:**
118+
> Avoid calling `register`, `unregister` , `subscribe` inside the side-effect function. (here named `doSideEffect`)
119+
120+
## Impact
121+
122+
![rx-angular--state--effects--motivation-process-diagram--michael-hladky](https://user-images.githubusercontent.com/10064416/154173507-049815ea-ee2a-4569-8a4d-6abdc319a349.png)
123+
124+
Compared to common approaches `rxEffects` does not rely on additional decorators or operators.
125+
In fact, it removes the necessity of the `subscribe`.
126+
127+
This results in less boilerplate and a good guidance to resilient and ergonomic component architecture.
128+
Furthermore, the optional imperative methods help to glue third party libs and a mixed but clean code style in Angular.

libs/state/effects/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { RxEffects } from './lib/effects.service';
2+
export { rxEffects } from './lib/rx-effects';
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { rxEffects } from '@rx-angular/state/effects';
2+
import { Component, inject, Injectable, InjectionToken } from '@angular/core';
3+
import { debounceTime, of, Subject, timer } from 'rxjs';
4+
import { TestBed } from '@angular/core/testing';
5+
import { wait } from 'nx-cloud/lib/utilities/waiter';
6+
7+
type Movie = {};
8+
9+
@Injectable({ providedIn: 'root' })
10+
export class LocalStorage {
11+
items = {};
12+
13+
setItem(prop: string, value: string) {
14+
this.items[prop] = value;
15+
}
16+
17+
removeItem(prop: string) {
18+
delete this.items[prop];
19+
}
20+
21+
getItem(prop: string) {
22+
return this.items[prop];
23+
}
24+
}
25+
26+
@Component({
27+
template: ` <input name="title" (change)="change.next(title.value)" #title />
28+
<button name="save" (click)="save()">Save</button>`,
29+
})
30+
class ListComponent {
31+
private change = new Subject<string>();
32+
private localStorage = inject(LocalStorage);
33+
34+
private ef = rxEffects(({ register }) => {
35+
const updateBackup = (title) => this.localStorage.setItem('title', title);
36+
register(this.change.pipe(debounceTime(300)), updateBackup);
37+
});
38+
39+
save() {
40+
localStorage.removeItem('editName');
41+
}
42+
}
43+
44+
// Test helper code ==========
45+
46+
function setupComponent() {
47+
TestBed.configureTestingModule({
48+
declarations: [ListComponent],
49+
});
50+
51+
const localStorage = TestBed.inject(LocalStorage);
52+
53+
const fixture = TestBed.createComponent(ListComponent);
54+
const component = fixture.componentInstance;
55+
56+
const searchInputElem: HTMLInputElement = fixture.nativeElement.querySelector(
57+
'input[name="title"]'
58+
);
59+
const searchInputChange = (value: string) => {
60+
searchInputElem.value = value;
61+
searchInputElem.dispatchEvent(new Event('change'));
62+
};
63+
64+
return { fixture, component, localStorage, searchInputChange };
65+
}
66+
67+
describe('effects usage in a component', () => {
68+
afterEach(() => {
69+
jest.restoreAllMocks();
70+
});
71+
72+
test('should ', async () => {
73+
const { component, fixture, localStorage, searchInputChange } =
74+
setupComponent();
75+
76+
const spySetItem = jest.spyOn(localStorage, 'setItem');
77+
const spyRemoveItem = jest.spyOn(localStorage, 'removeItem');
78+
79+
expect(spySetItem).toBeCalledTimes(0);
80+
searchInputChange('abc');
81+
expect(spySetItem).toBeCalledTimes(0); // debounceed
82+
await wait(350);
83+
expect(spySetItem).toBeCalledTimes(1);
84+
expect(spySetItem).toBeCalledWith('title', 'abc');
85+
});
86+
});
87+
88+
function setupComponent2() {
89+
TestBed.configureTestingModule({
90+
declarations: [ListComponent],
91+
});
92+
93+
const localStorage = TestBed.inject(LocalStorage);
94+
95+
const fixture = TestBed.createComponent(ListComponent);
96+
const component = fixture.componentInstance;
97+
98+
const saveButtonElem: HTMLInputElement = fixture.nativeElement.querySelector(
99+
'button[name="save"]'
100+
);
101+
const saveButtonClick = () => {
102+
saveButtonElem.dispatchEvent(new Event('change'));
103+
};
104+
105+
return { fixture, component, localStorage };
106+
}

0 commit comments

Comments
 (0)