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 (
| asyncrepeated 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 → templateAsyncPipe creates a scattered flow:
Observable → derive → pipe everywhere → async everywhereGlobal state creates a fragile flow:
subscribe → mutate → hope CD runs4. 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.