Problem Statement
Front-end state bugs often stem from active UI elements during pending HTTP requests. Unblocked buttons or forms allow repeated clicks and concurrent edits, sending duplicate requests to the server and causing state divergence
- Duplicate POST/PUT/DELETE requests from repeated interactions.
- Follow-up refresh calls racing the mutation they reflect.
- Related actions (Save + Reset, Submit + Refresh) remaining clickable during active requests.
- Ad hoc
loadingbooleans scattered across components. - Loading flags stuck at
truewhen an error path skips resetting them. - Disabled state logic drifting out of sync with the HTTP request lifecycle.
Toggling component-level booleans works for single requests, but degrades quickly as complexity grows. Sequential refreshes, related actions, or panel states require separate flags that must be manually reset on every error path
@Component({
selector: 'ngx-save-user',
template: `
<button (click)="save()" [disabled]="loading">Save</button>
`,
})
export class SaveUser {
private readonly http = inject(HttpClient);
protected loading = false;
protected save(): void {
this.loading = true;
this.http.post('/api/users', this.form.value).subscribe({
next: () => (this.loading = false),
error: () => (this.loading = false),
});
}
}
A shared requestId tracks the entire flow through HttpContext. An interceptor updates a reference-counted signal service: all directives and wrappers bound to that ID unlock automatically when all requests settle, including on error
@Component({
selector: 'ngx-save-user',
imports: [RequestLockDirective],
template: `
<!--
One shared `requestId` coordinates the whole flow:
both buttons and the follow-up GET are locked together
and only re-enable when everything settles.
-->
<button
ngxRequestLock
[requestId]="flowId()"
(click)="save()"
>
Save
</button>
<button
ngxRequestLock
[requestId]="flowId()"
(click)="refresh()"
>
Refresh
</button>
`,
})
export class SaveUser {
private readonly http = inject(HttpClient);
protected readonly flowId = signal(crypto.randomUUID());
protected save(): void {
const id = this.flowId();
this.http
.post('/api/users', this.form.value, {
context: createRequestLockContext(id),
})
// Automatic follow-up: same id, same lock.
.subscribe(() => this.refresh());
}
protected refresh(): void {
this.http
.get('/api/users', {
context: createRequestLockContext(this.flowId()),
})
.subscribe();
}
}
- Does not cancel or debounce requests.
- Does not replace server-side HTTP idempotency.
- Does not provide global spinners or toast notifications.