michaelschoenbaechler
michaelschoenbaechler

Reputation: 21

Refactoring an angular component with RxJS to use signal

I am currently refactoring an Angular component that uses RxJS, aiming to implement signals. The component's requirements are:

  1. Retrieve and render a business object.
  2. Display a loading indicator.
  3. Show an error screen if there's an issue, with a retry/refresh option.

Current Implementation

Here's the current implementation of my component:

class BusinessDetailPage implements OnInit {
  business: Business = null;
  loading = true;
  error = false;

  trigger$ = new Subject<void>();

  constructor(/** ... */) {}

  ngOnInit() {
    from(this.trigger$)
      .pipe(
        untilDestroyed(this),
        tap(() => (this.loading = true)),
        switchMap(() =>
          this.businessService
            .getBusiness(parseInt(this.route.snapshot.params.id))
            .pipe(
              first(),
              catchError(() => {
                this.error = true;
                this.loading = false;
                return of(null);
              })
            )
        )
      )
      .subscribe((business) => {
        this.business = business;
        this.loading = false;
      });
  }

  retry() {
    this.trigger$.next();
  }
}

Approach 1

Inspired by an example I found (signal-error-example), I refactored my component as follows:

export class BusinessDetailPage {
  trigger$ = new Subject<void>();
  business = toSignalWithError(this.fetchBusiness());
  isLoading = computed(() => !this.business()?.value && !this.business()?.error);

  constructor(/** ... */) {
    this.trigger$.next();
  }

  refresh() {
    this.trigger$.next();
  }

  private fetchBusiness() {
    return from(this.trigger$).pipe(
      untilDestroyed(this),
      switchMap(() =>
        this.businessService.getBusiness(
          parseInt(this.route.snapshot.params.id)
        )
      )
    );
  }
}

This approach looks clean, but I'm unsure how to effectively display the loading indicator, particularly when an error or content is already present.

Approach 2

Alternatively, I considered a slightly more verbose approach:

export class BusinessDetailPage {
  trigger$ = new Subject<void>();
  business = toSignal(this.fetchBusiness());
  error = signal(false);
  isLoading = signal(false);

  constructor(/** ... */) {
    this.trigger$.next();
  }

  refresh() {
    this.trigger$.next();
  }

  private fetchBusiness() {
    return from(this.trigger$).pipe(
      untilDestroyed(this),
      tap(() => {
        this.error.set(false);
        this.isLoading.set(true);
      }),
      switchMap(() =>
        this.businessService
          .getBusiness(parseInt(this.route.snapshot.params.id))
          .pipe(
            catchError(() => {
              this.error.set(true);
              return of(null);
            }),
            finalize(() => this.isLoading.set(false))
          )
      )
    );
  }
}

This approach seems to cover all requirements but my goal was to derive (compute) the isLoading from the signal, which is not the case anymore.

Questions

  1. Between these two approaches, which is considered best practice in terms of code efficiency and readability?
  2. Is there a more optimized way to handle the loading and error states in the context of using Signals?
  3. Any suggestions on improving the refactoring process for this scenario?

Upvotes: 2

Views: 503

Answers (1)

Pawel Twardziak
Pawel Twardziak

Reputation: 804

I think the best scenario is to combine rxjs and signals for a sake of efficient reactivity.

See this stackblitz bro.

Some pieces of the code:

Service:

@Injectable({ providedIn: 'root' })
export class BusinessService {
  businessData = signal<any | null>(null);
  loading = signal(false);
  error = signal(false);

  router = inject(Router);
  http = inject(HttpClient);

  #loadingQueue = new Subject<number>();

  loadingQueue = this.#loadingQueue.pipe(
    switchMap((id) => {
      this.error.set(false);
      this.loading.set(true);
      const simulateError = Math.random() < 0.5;
      return this.http
        .get(
          `https://jsonplaceholder.typicode.com/todos${
            simulateError ? 'x' : ''
          }/${id}`
        )
        .pipe(
          catchError(() => {
            this.error.set(true);
            return of(null);
          }),
          tap((response) => {
            this.businessData.set(response);
          }),
          finalize(() => this.loading.set(false))
        );
    })
  );

  loadBusiness(id: number) {
    this.loading.set(true);
    this.#loadingQueue.next(id);
  }

  changeBusiness() {
    this.router.navigate(['business', Math.floor(Math.random() * 100)]);
  }
}

Component:

@Component({
  selector: 'app-business',
  standalone: true,
  templateUrl: './business.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [NgIf, JsonPipe],
})
export class BusinessComponent {
  id = input.required<number>();
  businessService = inject(BusinessService);

  constructor() {
    this.businessService.loadingQueue.pipe(takeUntilDestroyed()).subscribe();
    effect(() => {
      const id = this.id();
      untracked(() => this.businessService.loadBusiness(id));
    });
  }
}

HTML:

<h3>The business {{ id() }}</h3>
<h5>
  <button
    [disabled]="businessService.loading()"
    (click)="businessService.changeBusiness()"
  >
    Change business
  </button>
</h5>
<p *ngIf="businessService.error()" style="color: red; font-weight: bolder;">
  Error occured!
</p>
<pre *ngIf="businessService.businessData() as data">{{ data | json }}</pre>

Bootrapping the App:

@Component({
  selector: 'app-root',
  standalone: true,
  template: `
    <h1>Hello from {{ name }}!</h1>
    <router-outlet></router-outlet>
  `,
  imports: [RouterOutlet],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
  name = 'Angular';
}

bootstrapApplication(App, {
  providers: [
    provideRouter(
      [
        {
          path: '',
          pathMatch: 'full',
          redirectTo: 'business/1',
        },
        {
          path: 'business/:id',
          loadComponent: () =>
            import('./business.component').then((c) => c.BusinessComponent),
        },
      ],
      withComponentInputBinding()
    ),
    provideHttpClient(),
  ],
});

Upvotes: 2

Related Questions