Inline Row Editing with Change Tracking in LWC Tables
Inline editing in table rows allows users to modify data directly without opening a separate form. The key challenge is tracking which rows have changed, computing derived values on the fly, and collecting all edits for a single batch save.
Separating Edit State from Display State
The most reliable pattern uses a Map to store edited values independently from the original data. This avoids mutating the source records and makes it straightforward to identify what has changed.
import { LightningElement, api, track } from "lwc";
import saveChanges from "@salesforce/apex/OrderItemController.saveChanges";
import { ShowToastEvent } from "lightning/platformShowToastEvent";
export default class EditableTable extends LightningElement {
@api recordId;
@track items = [];
_editedValues = new Map();
get displayItems() {
return this.items.map((item) => {
const edits = this._editedValues.get(item.Id) || {};
const quantity = edits.quantity !== undefined
? edits.quantity
: item.Quantity__c;
const unitPrice = item.Unit_Price__c || 0;
return {
...item,
displayQuantity: quantity,
displayTotal: quantity * unitPrice,
isEdited: this._editedValues.has(item.Id),
rowClass: this._editedValues.has(item.Id)
? "slds-is-edited"
: "",
};
});
}
}
The displayItems getter merges original data with any pending edits, recalculates derived fields, and marks edited rows for visual distinction. Because it is a getter, it re-evaluates whenever items or the reactivity trigger changes.
Handling Input Changes
Each input change updates the Map and forces a reactive update by reassigning the tracked array.
handleQuantityChange(event) {
const recordId = event.target.dataset.id;
const newValue = parseInt(event.target.value, 10);
const item = this.items.find((i) => i.Id === recordId);
const minQty = item.Min_Quantity__c || 1;
const maxQty = item.Max_Quantity__c || 999999;
// Enforce constraints
const constrainedValue = Math.min(Math.max(newValue, minQty), maxQty);
const existing = this._editedValues.get(recordId) || {};
this._editedValues.set(recordId, {
...existing,
quantity: constrainedValue,
});
// Trigger reactivity
this.items = [...this.items];
}
Template with Inline Inputs
<template>
<table class="slds-table slds-table_bordered">
<thead>
<tr>
<th>Item</th>
<th>Quantity</th>
<th>Unit Price</th>
<th>Total</th>
</tr>
</thead>
<tbody>
<template for:each={displayItems} for:item="row">
<tr key={row.Id} class={row.rowClass}>
<td>{row.Name}</td>
<td>
<lightning-input
type="number"
value={row.displayQuantity}
data-id={row.Id}
onchange={handleQuantityChange}
min={row.Min_Quantity__c}
max={row.Max_Quantity__c}
variant="label-hidden"
></lightning-input>
</td>
<td>
<lightning-formatted-number
value={row.Unit_Price__c}
style="currency"
currency-code="USD"
></lightning-formatted-number>
</td>
<td>
<lightning-formatted-number
value={row.displayTotal}
style="currency"
currency-code="USD"
></lightning-formatted-number>
</td>
</tr>
</template>
</tbody>
</table>
<div class="slds-m-top_medium">
<lightning-button
label="Save Changes"
variant="brand"
onclick={handleSave}
disabled={saveDisabled}
></lightning-button>
<lightning-button
label="Cancel"
onclick={handleCancel}
class="slds-m-left_small"
></lightning-button>
</div>
</template>
Batch Save Pattern
Collect all changes from the Map and send them to Apex in a single call to minimize DML operations and server round-trips.
get saveDisabled() {
return this._editedValues.size === 0;
}
async handleSave() {
const changes = [];
this._editedValues.forEach((edits, recordId) => {
changes.push({
Id: recordId,
Quantity__c: edits.quantity,
});
});
try {
await saveChanges({ updates: JSON.stringify(changes) });
this._editedValues.clear();
this.items = [...this.items]; // refresh display
this.dispatchEvent(
new ShowToastEvent({
title: "Success",
message: `${changes.length} item(s) updated.`,
variant: "success",
})
);
} catch (error) {
this.dispatchEvent(
new ShowToastEvent({
title: "Error",
message: error.body?.message || "Save failed.",
variant: "error",
})
);
}
}
handleCancel() {
this._editedValues.clear();
this.items = [...this.items];
}
Validation Before Save
Check for invalid entries and highlight them before allowing the save to proceed:
get hasValidationErrors() {
let hasErrors = false;
this._editedValues.forEach((edits, recordId) => {
const item = this.items.find((i) => i.Id === recordId);
if (edits.quantity < (item.Min_Quantity__c || 1)) {
hasErrors = true;
}
});
return hasErrors;
}
The Immutability Requirement
LWC reactivity depends on reference changes. Mutating an object property inside an existing array does not trigger a re-render. Always create new array and object instances:
// Will NOT trigger reactivity
this.items[0].Quantity__c = 5;
// WILL trigger reactivity
this.items = this.items.map((item, index) =>
index === 0 ? { ...item, Quantity__c: 5 } : item
);
This Map-based change tracking pattern scales well for tables with dozens of editable rows and keeps the code straightforward to maintain. The separation between source data and edit state makes it easy to implement undo, highlight dirty rows, or conditionally enable save buttons.