Fetching data in Angular used to be simple: inject a service, call .subscribe(), update a field. But with Angular's modern reactivity model — Signals, toSignal(), computed(), zoneless apps, and @if/@for — developers have multiple ways to structure UI state.

So I built a tiny app with three different implementations of the same feature:

- Fetch a Todo list

- Handle loading, errors, counts

- Render with @if and @for

Using:

  • Signals
  • AsyncPipe
  • Traditional component fields

And then I compared them.

Below is the result — and the clear winner might surprise you.

The API

All three components use the same simple service:

export interface Todo {
  id: number;
  title: string;
  completed: boolean;
}
@Injectable({ providedIn: 'root' })
export class TodosApiService {
  private readonly http = inject(HttpClient);
  getTodos(): Observable<Todo[]> {
    return this.http.get<Todo[]>('https://jsonplaceholder.typicode.com/todos?_limit=5');
  }
}

1) Global Component State (Imperative subscribe + class fields)

@Component({
  selector: 'app-todos-global-state',
  changeDetection: ChangeDetectionStrategy.Default,
  standalone: true,
  template: `
    <section>
      <h2>Todos ({{ totalCount }})</h2>
@if(isLoading){ <p>Loading…</p> }
      @if(error){ <p>Error: {{ error }}</p> }
      @if(!isLoading && !error && hasTodos){
        <ul>
          @for(todo of todos; track todo.id){
            <li><input type="checkbox" [checked]="todo.completed" disabled />{{ todo.title }}</li>
          }
        </ul>
      }
      @if(!isLoading && !error && !hasTodos){
        <p>No todos found.</p>
      }
      <p>Completed: {{ completedCount }}</p>
    </section>
  `,
})
export class TodosGlobalStateComponent implements OnInit {
  private readonly api = inject(TodosApiService);
  private readonly destroyRef = inject(DestroyRef);
  todos: Todo[] = [];
  isLoading = false;
  error: string | null = null;
  get hasTodos() { return this.todos.length > 0; }
  get totalCount() { return this.todos.length; }
  get completedCount() { return this.todos.filter(t => t.completed).length; }
  ngOnInit(): void {
    this.isLoading = true;
    this.api
      .getTodos()
      .pipe(
        takeUntilDestroyed(this.destroyRef),
        finalize(() => (this.isLoading = false))
      )
      .subscribe({
        next: todos => (this.todos = todos),
        error: err => (this.error = err?.message ?? 'Unknown error'),
      });
  }
}

✅ Pros

  • Most familiar to Angular devs
  • Easy to read
  • Works without understanding signals or RxJS deeply
  • Everything is "just variables"

❌ Cons

  • Manual subscription
  • Manual loading state
  • Manual error handling
  • Not auto-reactive
  • Not ideal for OnPush or zoneless Angular

Summary

Good for beginners / quick demos, but not scalable.

2) AsyncPipe + RxJS (The classic reactive way)

@Component({
  selector: 'app-todos-async-pipe',
  imports: [AsyncPipe],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <section>
      <h2>Todos ({{ (totalCount$ | async) ?? 0 }})</h2>
@if(isLoading$ | async){ <p>Loading…</p> }
      @if(error$ | async; as error){ <p>Error: {{ error }}</p> }
      @if(!(isLoading$ | async) && !(error$ | async) && (hasTodos$ | async)){
        <ul>
          @for(todo of (todos$ | async); track todo.id){
            <li><input type="checkbox" [checked]="todo.completed" disabled />{{ todo.title }}</li>
          }
        </ul>
      }
      @if(!(isLoading$ | async) && !(error$ | async) && !(hasTodos$ | async)){
        <p>No todos found.</p>
      }
      <p>Completed: {{ (completedCount$ | async) ?? 0 }}</p>
    </section>
  `,
})
export class TodosAsyncPipeComponent {
  private readonly api = inject(TodosApiService);
  readonly todos$ = this.api.getTodos().pipe(
    catchError(err => {
      this._errorMessage = err?.message ?? 'Unknown error';
      return of<Todo[]>([]);
    })
  );
  private _errorMessage: string | null = null;
  readonly error$ = of(this._errorMessage);
  readonly isLoading$ = of(false);
  readonly hasTodos$ = this.todos$.pipe(map(t => t.length > 0));
  readonly totalCount$ = this.todos$.pipe(map(t => t.length));
  readonly completedCount$ = this.todos$.pipe(map(t => t.filter(x => x.completed).length));
}

✅ Pros

  • Zero subscriptions
  • Very stable, very common in Angular apps
  • Pure RxJS (no signals needed)
  • Works great for streaming or live data

❌ Cons

  • Template becomes noisy (| async repeated everywhere)
  • Every access triggers change detection
  • Harder to read compared to signals
  • Harder to derive multiple state pieces

Summary

Still solid, but verbose. Signals simply express UI state better.

Good for beginners / quick demos, but not scalable.

3) Signals + toSignal() + computed() (Modern Angular)

@Component({
  selector: 'app-todos-signal',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <section>
      <h2>Todos ({{ totalCount() }})</h2>
      @if(isLoading()){ <p>Loading…</p> }
      @if(error()){ <p>Error: {{ error() }}</p> }
@if(!isLoading() && !error() && hasTodos()){
        <ul>
          @for(todo of todos(); track todo.id){
            <li><input type="checkbox" [checked]="todo.completed" disabled />{{ todo.title }}</li>
          }
        </ul>
      }
      @if(!isLoading() && !error() && !hasTodos()){
        <p>No todos found.</p>
      }
      <p>Completed: {{ completedCount() }}</p>
    </section>
  `
})
export class TodosSignalComponent {
  private readonly api = inject(TodosApiService);
  private readonly _error = signal<string | null>(null);
  readonly error = this._error.asReadonly();
  private readonly todos$ = this.api.getTodos().pipe(
    catchError(err => {
      this._error.set(err?.message ?? 'Unknown error');
      return of<Todo[]>([]);
    })
  );
  private readonly _todos = toSignal(this.todos$, { initialValue: [] as Todo[] });
  readonly todos = computed(() => this._todos());
  readonly isLoading = computed(() => this.todos().length === 0 && !this.error());
  readonly hasTodos = computed(() => this.todos().length > 0);
  readonly totalCount = computed(() => this.todos().length);
  readonly completedCount = computed(() => this.todos().filter(t => t.completed).length);
}

✅ Pros

  • Best ergonomic API for UI state
  • computed() makes derived state trivial
  • Runs without ngOnInit
  • Fully reactive, future-proof, no manual subscription
  • Plays well with zoneless Angular

Summary

The most elegant, reactive, modern way to handle UI state in Angular.

Which One Is Best? (Final Verdict)

Signals → (toSignal() + computed())

Here's why:

1. Cleanest UI state representation

UI state becomes pure math:

readonly isLoading = computed(() => this.todos().length === 0 && !this.error());
readonly completedCount = computed(() => this.todos().filter(t => t.completed).length);

✅ No subscriptions. ✅ No manual change detection. ✅ No async pipe spam.

2. Designed for future Angular (zoneless / SSR / resumability)

Signals don't rely on Zones. AsyncPipe does. Imperative subscribe definitely does.

3. Best readability → best maintainability

Signals create a clean flow:

Observable → toSignal → computed → template

AsyncPipe creates a scattered flow:

Observable → derive → pipe everywhere → async everywhere

Global state creates a fragile flow:

subscribe → mutate → hope CD runs

4. Easy to test, easy to reason about, easy to scale

Signals scale better in complex UIs:

  • tabs
  • filters
  • paginate
  • derived state
  • multi-source state merges

RxJS can do it, but with more cognitive overhead.

👉 Working StackBlitz (all 3 approaches included):

This StackBlitz contains:

  • TodosSignalComponent
  • TodosAsyncPipeComponent
  • TodosGlobalStateComponent
  • TodosApiService
  • Working templates using @if / @for
  • Switching between components in main.ts

Everything runs out of the box.