Skip to content

Angular Loading States

Every async operation has a moment before it resolves, and users need visual feedback during that time. This lesson covers clean patterns for representing and rendering loading state in Angular.

Why Loading States Matter

Without explicit loading feedback, users see a blank or frozen UI while a request is in flight, and can't tell whether the app is working or broken. A well-modeled loading state answers three questions at once: is data loading, did it fail, and what's the current data (if any)?

The simplest approach uses a boolean flag; more complete approaches model loading, error, and data as a single combined state to avoid impossible combinations (like showing data and an error at the same time).

export class UserListComponent {
  private userService = inject(UserService);
  users: User[] = [];
  isLoading = false;
  errorMessage?: string;

  ngOnInit() {
    this.isLoading = true;
    this.userService.getUsers().subscribe({
      next: users => { this.users = users; this.isLoading = false; },
      error: () => { this.errorMessage = 'Failed to load users.'; this.isLoading = false; },
    });
  }
}

This basic pattern uses three separate properties; the template checks each to decide what to render.

Rendering Loading State in a Template

@if (isLoading) {
  <app-spinner />
} @else if (errorMessage) {
  <p class="error">{{ errorMessage }}</p>
} @else {
  <!-- render data -->
}
  • Checking loading first, then error, then the success/data case avoids overlapping UI states.
  • A dedicated loading indicator component (like a spinner) keeps this pattern consistent across the app.
  • Signals (signal(), computed()) work naturally for this pattern in newer Angular code.
  • The async pipe combined with RxJS operators can also model this without manual subscription code.

Loading States Cheatsheet

Common approaches for representing async UI state.

Approach Example Notes
Boolean flag isLoading = false; Simplest, but doesn't combine error/data cleanly
Signals isLoading = signal(false); Reactive, works well with computed()
Discriminated union state { status: 'loading' | 'error' | 'success', ... } Prevents impossible UI combinations
Async pipe + startWith data$.pipe(startWith('loading')) Declarative, no manual subscription
Loading spinner component <app-spinner /> Consistent visual feedback across the app

Signal-Based Loading State

Signals make loading state feel natural to update and read, and integrate cleanly with computed values, like deriving whether to show an empty-state message from both the loading flag and the data itself.

export class UserListComponent {
  private userService = inject(UserService);
  users = signal<User[]>([]);
  isLoading = signal(false);

  isEmpty = computed(() => !this.isLoading() && this.users().length === 0);

  ngOnInit() {
    this.isLoading.set(true);
    this.userService.getUsers().subscribe(users => {
      this.users.set(users);
      this.isLoading.set(false);
    });
  }
}

Modeling Loading State as a Discriminated Union

A more robust pattern models the whole request lifecycle as a single value with a status field, making impossible combinations (like isLoading: true and data already present, contradicting each other) unrepresentable.

type RequestState<T> =
  | { status: 'loading' }
  | { status: 'error'; message: string }
  | { status: 'success'; data: T };

state = signal<RequestState<User[]>>({ status: 'loading' });

<!-- template -->
@switch (state().status) {
  @case ('loading') { <app-spinner /> }
  @case ('error') { <p>{{ state().message }}</p> }
  @case ('success') { <!-- render state().data --> }
}

A Reusable Loading Wrapper Component

Extracting loading/error/empty presentation into a small, reusable wrapper component avoids repeating the same @if/@else chain in every list or detail view across the app.

@Component({
  selector: 'app-async-state',
  standalone: true,
  template: `
    @if (loading()) {
      <app-spinner />
    } @else if (error()) {
      <p class="error">{{ error() }}</p>
    } @else {
      <ng-content></ng-content>
    }
  `,
})
export class AsyncStateComponent {
  loading = input(false);
  error = input<string | null>(null);
}

Common Mistakes

  • Forgetting to reset isLoading back to false in both the success and error callbacks, leaving a spinner stuck forever on failure.
  • Showing both an error message and stale previous data at the same time with no clear visual hierarchy.
  • Using three unrelated booleans (isLoading, hasError, hasData) instead of a single combined state, allowing contradictory combinations.
  • Not showing any loading feedback at all for requests that occasionally take longer than expected.

Key Takeaways

  • Explicit loading feedback tells users the app is working, not frozen or broken.
  • Signals and computed values make loading/error/empty state easy to derive and keep in sync.
  • Modeling state as a single discriminated union avoids impossible combinations of loading, error, and data.
  • A reusable loading wrapper component avoids repeating the same conditional structure everywhere.

Pro Tip

Model async state as a single value with a status discriminant rather than several independent booleans; it makes invalid combinations (like loading and success at once) impossible to represent, eliminating a whole class of UI bugs.