Global Loading State and Optimistic UX Patterns

๐Ÿ“‹ Table of Contents โ–พ
  1. LoadingService and Global Progress Bar
  2. Common Mistakes

Loading state and optimistic UX patterns separate good applications from great ones. A global loading indicator removes the need for per-component loading flags on most operations. Optimistic updates make mutations feel instant. Skeleton loading screens keep the page visually stable during data fetching. Together, these patterns create the polished, responsive feel of a production application.

LoadingService and Global Progress Bar

// โ”€โ”€ LoadingService โ€” reference-counted loading state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@Injectable({ providedIn: 'root' })
export class LoadingService {
  private _count  = signal(0);
  readonly isLoading = computed(() => this._count() > 0);

  start(): void { this._count.update(n => n + 1); }
  stop():  void { this._count.update(n => Math.max(0, n - 1)); }
}

// โ”€โ”€ Global progress bar component โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@Component({
  selector:  'app-progress-bar',
  standalone: true,
  template: `
    @if (loading.isLoading()) {
      <div class="progress-bar" role="progressbar" aria-label="Loading">
        <div class="progress-bar__fill"></div>
      </div>
    }
  `,
  styles: [`
    .progress-bar {
      position: fixed; top: 0; left: 0; right: 0;
      height: 3px; z-index: 10001; background: transparent;
    }
    .progress-bar__fill {
      height: 100%; background: var(--mat-sys-primary);
      animation: progress 1.5s ease-in-out infinite;
    }
    @keyframes progress {
      0%   { width: 0%;   margin-left: 0; }
      50%  { width: 75%;  margin-left: 0; }
      100% { width: 0%;   margin-left: 100%; }
    }
  `],
})
export class ProgressBarComponent {
  protected loading = inject(LoadingService);
}

// โ”€โ”€ Skeleton loading screen โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@Component({
  selector:   'app-post-card-skeleton',
  standalone:  true,
  template: `
    <article class="post-card skeleton">
      <div class="skeleton-block cover"></div>
      <div class="card-content">
        <div class="skeleton-block avatar-row"></div>
        <div class="skeleton-block title"></div>
        <div class="skeleton-block excerpt-1"></div>
        <div class="skeleton-block excerpt-2"></div>
      </div>
    </article>
  `,
  styles: [`
    .skeleton-block {
      background: linear-gradient(90deg,
        var(--mat-sys-surface-variant) 25%,
        var(--mat-sys-surface) 50%,
        var(--mat-sys-surface-variant) 75%);
      background-size: 200% 100%;
      animation: shimmer 1.5s infinite;
      border-radius: 4px;
    }
    @keyframes shimmer { 0% { background-position: 200% 0; }
                          100% { background-position: -200% 0; } }
    .cover      { height: 200px; border-radius: 8px 8px 0 0; }
    .avatar-row { height: 32px; width: 60%; margin: 1rem 0 .5rem; }
    .title      { height: 24px; width: 85%; margin-bottom: .75rem; }
    .excerpt-1  { height: 16px; width: 100%; margin-bottom: .25rem; }
    .excerpt-2  { height: 16px; width: 70%; }
  `],
})
export class PostCardSkeletonComponent {}

// โ”€โ”€ Route-level loading with router events โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@Injectable({ providedIn: 'root' })
export class RouteLoadingService {
  private loading = inject(LoadingService);

  constructor(private router: Router) {
    this.router.events.pipe(
      filter(e => e instanceof NavigationStart  ||
                  e instanceof NavigationEnd    ||
                  e instanceof NavigationCancel ||
                  e instanceof NavigationError),
    ).subscribe(event => {
      if (event instanceof NavigationStart) this.loading.start();
      else                                  this.loading.stop();
    });
  }
}
Note: The reference-counted LoadingService correctly handles concurrent requests. If 3 requests are in-flight simultaneously, _count is 3. As each completes (finalize() in the loading interceptor), the count decrements. The spinner stays visible until all three complete (_count reaches 0). A simple boolean flag (isLoading = true/false) breaks with concurrent requests โ€” setting it to false when the first request completes hides the spinner while two more are still running.
Tip: Skeleton loading screens match the layout of the content they replace โ€” same card shape, same approximate content dimensions. This prevents the page from visually “jumping” when real content loads (the skeleton reserves the same space). Pure spinners (a single spinner in the center) cause layout shift when removed. CSS shimmer animation (the moving gradient) signals “loading” clearly without being distracting. Use Angular Material’s MatSkeletonLoaderModule or build custom skeletons with the shimmer pattern shown above.
Warning: Optimistic updates must always have a rollback mechanism. When you add a comment optimistically and the server returns an error, the optimistic comment must be removed from the UI. If the rollback is not implemented, users see phantom data โ€” items that appear to exist but don’t actually exist on the server. Always pair optimistic adds with rollback on error, and optimistic deletes with restore on error. The rollback should also restore any form state so the user can retry without re-entering their data.

Common Mistakes

Mistake 1 โ€” Boolean flag for loading with concurrent requests (spinner hides prematurely)

โŒ Wrong โ€” isLoading = false when first of 3 concurrent requests completes; spinner disappears while 2 requests still running.

โœ… Correct โ€” reference-counted counter; spinner stays until all concurrent requests complete.

Mistake 2 โ€” Optimistic update without rollback (phantom data in UI)

โŒ Wrong โ€” optimistic comment added to list; server returns 422; comment stays in UI but doesn’t exist in database.

โœ… Correct โ€” error handler removes optimistic item and restores form; user sees failure state and can retry.

🧠 Test Yourself

The loading interceptor calls loading.start() on request and loading.stop() in finalize(). Three concurrent requests run. One fails immediately (network error). Is the spinner still shown?