Async Operation State Management in Lightning Web Components
Asynchronous operations -- Apex calls, refreshApex, record updates -- introduce timing complexity in LWC components. Without deliberate state management, users can trigger duplicate operations, see stale UI, or miss error feedback entirely.
The isProcessing Flag Pattern
The core pattern uses a boolean flag to gate UI interactions during async work. While processing, buttons are disabled and a spinner provides visual feedback.
import { LightningElement, api, wire } from "lwc";
import { refreshApex } from "@salesforce/apex";
import { ShowToastEvent } from "lightning/platformShowToastEvent";
import getItems from "@salesforce/apex/ItemController.getItems";
import processItems from "@salesforce/apex/ItemController.processItems";
export default class ItemProcessor extends LightningElement {
@api recordId;
items;
isProcessing = false;
_wiredResult;
@wire(getItems, { parentId: "$recordId" })
wiredItems(result) {
this._wiredResult = result;
if (result.data) {
this.items = result.data;
}
}
get isActionDisabled() {
return this.isProcessing || !this.items?.length;
}
async handleProcess() {
this.isProcessing = true;
try {
const result = await processItems({ parentId: this.recordId });
await refreshApex(this._wiredResult);
this.showToast("Success", `Processed ${result.count} items.`, "success");
} catch (error) {
this.showToast(
"Error",
error.body?.message || "An unexpected error occurred.",
"error"
);
} finally {
this.isProcessing = false;
}
}
showToast(title, message, variant) {
this.dispatchEvent(new ShowToastEvent({ title, message, variant }));
}
}
<template>
<lightning-card title="Item Processor">
<template if:true={isProcessing}>
<lightning-spinner alternative-text="Processing" size="small">
</lightning-spinner>
</template>
<!-- Table content -->
<template if:true={items}>
<table class="slds-table slds-table_bordered">
<!-- rows -->
</table>
</template>
<div slot="footer">
<lightning-button
label="Process Items"
variant="brand"
onclick={handleProcess}
disabled={isActionDisabled}
></lightning-button>
</div>
</lightning-card>
</template>
Why try-catch-finally Matters
The finally block guarantees that isProcessing is reset to false regardless of whether the operation succeeds or fails. Without it, an unhandled error leaves the UI permanently locked in a processing state.
// BAD: isProcessing never resets on error
async handleSave() {
this.isProcessing = true;
await saveRecords({ data: this.changes });
this.isProcessing = false; // never reached if saveRecords throws
}
// GOOD: finally guarantees cleanup
async handleSave() {
this.isProcessing = true;
try {
await saveRecords({ data: this.changes });
this.showToast("Success", "Records saved.", "success");
} catch (error) {
this.showToast("Error", error.body?.message, "error");
} finally {
this.isProcessing = false;
}
}
Preventing Double-Clicks
The isProcessing flag inherently prevents double-clicks because the button is disabled as soon as the first click begins processing. However, there is a brief window between the click event and the reactive update of the disabled attribute. For critical operations, add an early exit guard:
async handleProcess() {
if (this.isProcessing) return; // Guard against race condition
this.isProcessing = true;
try {
// ... operation
} finally {
this.isProcessing = false;
}
}
Combining with refreshApex
When an operation modifies data that the component displays via a wire adapter, chain refreshApex inside the try block after the mutation succeeds:
async handleApprove() {
this.isProcessing = true;
try {
await approveRecord({ recordId: this.selectedId });
await refreshApex(this._wiredResult);
this.showToast("Success", "Record approved.", "success");
} catch (error) {
this.showToast("Error", error.body?.message, "error");
} finally {
this.isProcessing = false;
}
}
The spinner remains visible through both the Apex call and the data refresh, giving the user a single uninterrupted "processing" state rather than a flash of stale data between the two operations.
The Full Lifecycle
The complete flow for a user-initiated async operation follows this sequence:
- User clicks a button
- Guard check -- exit early if already processing
- Set
isProcessing = true-- disables buttons, shows spinner - Call Apex -- the primary mutation
- Refresh data --
refreshApexorgetRecordNotifyChange - Show toast -- success feedback
finallyresetsisProcessing = false-- re-enables buttons, hides spinner
On failure, step 5 is skipped, an error toast replaces the success toast in step 6, and step 7 still executes.
Multiple Independent Operations
When a component has several independent async actions, use separate flags or a counter:
_activeOperations = 0;
get isProcessing() {
return this._activeOperations > 0;
}
async handleOperationA() {
this._activeOperations++;
try {
await operationA();
} finally {
this._activeOperations--;
}
}
This prevents one completing operation from prematurely re-enabling the UI while another is still in flight.
Consistent application of these patterns eliminates the most common async-related bugs in LWC: frozen UIs, duplicate submissions, missing error messages, and stale data displays.