Skip to main content

Loading state

ApiLoadingService counts in-flight requests and exposes a signal. It counts rather than flags, so two concurrent calls don't switch the indicator off when the first one finishes.

@Injectable({ providedIn: 'root' })
export class ApiLoadingService {
readonly loading: Signal<boolean>;
}

Using it

src/app/app.component.ts
@Component({
selector: 'app-root',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (loading.loading()) {
<div class="progress" role="status" aria-live="polite">
<span class="visually-hidden">Loading…</span>
</div>
}

<router-outlet />
`,
})
export class AppComponent {
protected readonly loading = inject(ApiLoadingService);
}
Accessibility

An indicator that only changes visually is invisible to a screen reader. Give the region role="status" with aria-live="polite" and a visually-hidden text label, as above, so the state change is announced.

What counts

Every ApiService call increments the counter on subscribe and decrements it when the observable completes, errors, or is unsubscribed — including on teardown, so a component destroyed mid-request never strands the counter.

The counter moves once per ApiService call, not once per HTTP attempt, so a retried request keeps the indicator up for the whole operation.

Because the request is built lazily, an observable you never subscribe to never touches the counter.

Excluding requests

Background polling and telemetry usually shouldn't spin a global indicator:

this.api.get<Notification[]>('/notifications', { showLoader: false });

Or invert the default and opt in per call:

provideApi({
baseUrl: 'https://api.example.com',
defaultShowLoader: false,
});
this.api.get<Order[]>('/orders', { showLoader: true });

Local loading state

ApiLoadingService is global by design. For a spinner scoped to one component, track it locally instead — the two coexist happily:

export class OrderListComponent {
private readonly orders = inject(OrderService);

protected readonly items = signal<Order[]>([]);
protected readonly busy = signal(false);

load(): void {
this.busy.set(true);
this.orders
.list(0, 20)
.pipe(finalize(() => this.busy.set(false)))
.subscribe((page) => this.items.set(page.content));
}
}

Consider showLoader: false on such calls so the component's own spinner isn't duplicated by the global bar.