Use effect less in Angular, try signals in computed

Connie Leung - Sep 13 - - Dev Community

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');
Enter fullscreen mode Exit fullscreen mode

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());
   });
 }
Enter fullscreen mode Exit fullscreen mode

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',
   };
 });
Enter fullscreen mode Exit fullscreen mode

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',
 },
Enter fullscreen mode Exit fullscreen mode

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(),
   };
 });
Enter fullscreen mode Exit fullscreen mode

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',
},
Enter fullscreen mode Exit fullscreen mode

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()),
   };
 });
Enter fullscreen mode Exit fullscreen mode

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);
   }
}
Enter fullscreen mode Exit fullscreen mode

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)" />
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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);
     }));
 }
}
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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()),
   };
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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:

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .