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();
});
}
}
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.MatSkeletonLoaderModule or build custom skeletons with the shimmer pattern shown above.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.