I watched a Youtube video where the Angular team lead, Alex Rickabaugh, discouraged the use of effect. Then, he demoed a way to replace effect with signals-in-computed that is not intuitive and would require developers to have a mental shift of having writable signals in a computed signal.
Today, I would like to replace explicitEffect with signals and computed state.
Previous implementation of explicitEffect
searchId = signal(initialId);
id = signal(initialId);
person = signal<undefined | Person>(undefined);
films = signal<string[]>([]);
rgb = signal('brown');
fontSize = computed(() => this.id() % 2 === 0 ? '1.25rem' : '1.75rem');
The component has some signals to store searchId
, id
, person
, films
and the random rgb
code. The fontSize
computed signal derives the font size based on the id.
#logIDsEffect = explicitEffect([this.searchId],
([searchId]) => console.log('id ->', this.id(), 'searchID ->', searchId), { defer: true });
#rgbEffect = explicitEffect([this.rgb], ([rgb]) => console.log('rgb ->', rgb), { defer: true });
constructor() {
explicitEffect([this.id], ([id], onCleanUp) => {
const sub = getPersonMovies(id, this.injector)
.subscribe((result) => {
if (result) {
const [person, ...rest] = result;
this.person.set(person);
this.films.set(rest);
} else {
this.person.set(undefined);
this.films.set([]);
}
this.rgb.set(generateRGBCode());
this.rgb.set(generateRGBCode());
this.rgb.set(generateRGBCode());
});
this.renderer.setProperty(this.hostElement, 'style', `--main-font-size: ${this.fontSize()}`);
if (id !== this.searchId()) {
this.searchId.set(id);
}
onCleanUp(() => sub.unsubscribe());
});
}
The component has three effects that either log the signals in the console or update them. These signals need to be replaced with a computed state.
Replace the fontSize computed signal
After the code review, the process is to retain the id
signal and eliminate the rest. The first step is to add a state
computed signal and remove the fontSize
computed signal.
state = computed(() => {
return {
fontSize: this.id() % 2 === 0 ? '1.25rem' : '1.75rem',
};
});
When the id
signal updates, the state
computed signal derives a new font size for the fontSize property.
host: {
'[style.--main-font-size]': 'state().fontSize',
},
Use the host
property instead of using the Renderer2
and ElementRef
to update the CSS variable.
Replace the rgb signal
state = computed(() => {
return {
fontSize: this.id() % 2 === 0 ? '1.25rem' : '1.75rem',
rgb: generateRGBCode(),
};
});
When the id
signal changes, the state
computed signal derives a new RGB code for the rgb
property. Similarly, the host
property also updates the CSS variable and I delete the #rgbEffect
effect so that it does not log the rgb changes.
host: {
'[style.--main-color]': 'state().rgb',
},
Replace the searchId signal
The searchId
signal needs more work than other signals. When the id
signal updates, it also has the same value. When the searchId
signal changes, the id
signal also receives the latest value.
state = computed(() => {
const result = this.#personMovies();
return {
fontSize: this.id() % 2 === 0 ? '1.25rem' : '1.75rem',
rgb: generateRGBCode(),
searchId: signal(this.id()),
};
});
In the state
computed signal, the searchId
property is a signal with the initial value this.id()
. When the id
signal changes subsequently, the computed signal synchronizes the value of the searchId
property.
syncId(id: number) {
if (id >= this.min && id <= this.max) {
this.state().searchId.set(id);
this.id.set(id);
}
}
When the user inputs a new id in the text field, the syncId
method sets both the searchId
property and the id
signal.
<input type="number" [ngModel]="state().searchId()" (ngModelChange)="syncId($event)" />
The input field cannot use two-way data binding to bind the searchId
signal to the ngModel
directive. The ngModelChange
event emitter invokes the syncId
method to update signals.
In the constructor, delete the RxJS code because it is not used.
toObservable(this.searchId).pipe(
debounceTime(300),
distinctUntilChanged(),
filter((value) => value >= this.min && value <= this.max),
map((value) => Math.floor(value)),
takeUntilDestroyed(),
).subscribe((value) => this.id.set(value));
I prefer the above RxJS code over the syncId
method; I would rather use the effect to synchronize the values of the id
and searchId
signals.
Use toSignal and toObservable to make HTTP request
function getPersonMovies(http: HttpClient) {
return function(source: Observable<Person>) {
return source.pipe(
mergeMap((person) => {
const urls = person?.films ?? [];
const filmTitles$ = urls.map((url) => http.get<{ title: string }>(url).pipe(
map(({ title }) => title),
catchError((err) => {
console.error(err);
return of('');
})
));
return forkJoin([Promise.resolve(person), ...filmTitles$]);
}),
catchError((err) => {
console.error(err);
return of(undefined);
}));
}
}
This is a custom RxJS operator to retrieve the details and films of a Star War character.
#personMovies = toSignal(toObservable(this.id)
.pipe(
switchMap((id) => this.http.get<Person>(`${URL}/${id}`)
.pipe(getPersonMovies(this.http))
),
), { initialValue: undefined });
The #personMovies
uses the toSignal
and toObservable
functions to create a signal of Star War details. I feel the toSignal(toObservable(this.id))
is long and not easy for beginners to understand.
state = computed(() => {
const result = this.#personMovies();
return {
fontSize: this.id() % 2 === 0 ? '1.25rem' : '1.75rem',
rgb: generateRGBCode(),
person: signal(result && result.length > 0 ? result[0] : undefined),
films: signal(result && result.length > 1 ? result.slice(1): []),
searchId: signal(this.id()),
};
});
If the HTTP request is successful, the result array is defined. The person
property is a signal and the value is the first element of the array. The films
property is a signal that tracks the remaining array elements.
<div class="border">
@if(state().person(); as person) {
<p>Id: {{ id() }} </p>
<p>Name: {{ person.name }}</p>
<p>Height: {{ person.height }}</p>
<p>Mass: {{ person.mass }}</p>
<p>Hair Color: {{ person.hair_color }}</p>
<p>Skin Color: {{ person.skin_color }}</p>
<p>Eye Color: {{ person.eye_color }}</p>
<p>Gender: {{ person.gender }}</p>
} @else {
<p>No info</p>
}
<p style="text-decoration: underline">Movies</p>
@for(film of state().films(); track film) {
<ul style="padding-left: 1rem;">
<li>{{ film }}</li>
</ul>
} @empty {
<p>No movie</p>
}
</div>
The HTML template updates to display person and films from the state
computed signal.
Conclusions:
- Angular team said not to abuse effect
- We can create signals-in-compute and update the properties when the signal they depend on changes
- Use toSignal and toObservable to make HTTP requests. toSignal(toObservable(this.id)) is long and hard to read, and we can check the toObservableSignal function in the ngxtension library.
Resources:
- Techstack Nation Don't use effect: https://www.youtube.com/watch?v=aKxcIQMWSNU&feature=youtu.be
- Stackblitz Demo: https://stackblitz.com/edit/stackblitz-starters-cejcoj?file=src%2Fstar-war%2Fstar-war.service.ts