LWC Datatable with Bulk Delete & Export to CSV
A reusable Lightning Web Component pattern: select multiple records in a datatable, review them in a confirmation modal, bulk-delete safely, or export the selection straight to a CSV file — no server round trip needed for the export.
Key Features
- ✓Bulk delete
- ✓Export selected records to CSV
- ✓Revert delete action if record is associate with another records
Technical Highlights
- ✓Apex Method to delete record
Watch It In Action
How It Works
What actually happens under the hood, step by step.
Select rows
The datatable's built-in checkbox column captures selected record Ids via the onrowselection handler -- no custom selection logic needed.
Preview before deleting
Selected Ids are sent to an Apex method that fetches just enough fields (Name, CaseNumber, etc. depending on object) to show a readable confirmation list in a modal, so nothing gets deleted blind.
Delete safely
The Apex delete method checks isDeletable() on the object describe before calling Database.delete, so it fails gracefully instead of throwing an unhandled exception on objects the user cannot delete.
Export without a server call
CSV export happens entirely client-side: selected rows are converted to CSV text and pushed to the browser as a Blob download, so exporting doesn't consume any Apex governor limits.
Before You Start
- ✓Access to a Salesforce org with Lightning Web Components enabled (any Developer Edition or sandbox works).
- ✓Read access to the object you point this at -- the sample uses Account, but objectName in the Apex controller can be changed to any standard or custom object.
- ✓Delete permission on that object for the running user, since the Apex layer checks isDeletable() before removing records.
- ✓Field-level access to whichever columns you reference in the SOQL query and datatable columns list (Name, AccountNumber, Rating, CreatedDate in this example).
Code Files
Exactly as used in the demo above. Copy into the matching file in your LWC bundle.
public with sharing class BulkDeleteController {
@AuraEnabled(cacheable=true)
public static List<sObject> getRecords(String objectName) {
String query = 'SELECT Id, Name, AccountNumber, Rating, CreatedDate FROM ' + objectName +' limit 200';
return Database.query(query);
}
@AuraEnabled(cacheable=true)
public static List<sObject> getRecordsForLstIds(List<String> recordIds, String objectName) {
try {
List<SObject> records;
if(String.isNotBlank(objectName)) {
// Handle Case Object
String fields = 'Id, Name';
if(objectName == 'Case') {
fields = 'Id, CaseNumber';
} else if(objectName == 'Task') {
fields = 'Id';
}
String query = 'SELECT ' + fields + ' FROM ' + objectName + ' WHERE Id IN :recordIds';
System.debug('query ' + query);
records= Database.query(query);
}
System.debug('records ' + records);
return records;
}
catch (Exception exc) {
return null;
}
}
@AuraEnabled
public static String deleteLstRecords(List <String> recordIds, String objectName) {
try {
Schema.DescribeSObjectResult checkObjectAccess = Schema.getGlobalDescribe().get(objectName).getDescribe();
if (checkObjectAccess.isDeletable()) {
List <Id> lstRecordsToDelete = new List <Id>();
Set <Id> deletedRecordsIds = new Set <Id>();
for (Id recordId: recordIds) {
lstRecordsToDelete.add(recordId);
}
Database.DeleteResult[] deleteResults = Database.delete(lstRecordsToDelete, true);
for (Database.DeleteResult deletedId: deleteResults) {
if (deletedId.isSuccess())
deletedRecordsIds.add(deletedId.getId());
}
}
return 'success';
} catch (Exception e) {
return 'Error deleting records: ' + e.getMessage();
}
}
} <template>
<div>
<div class="slds-card slds-p-around_large">
<div>
<div class="slds-p-bottom_large">
<lightning-button label="Delete records" variant="destructive" onclick={openModel} disabled={deleteButtonReadOnly}></lightning-button>
<lightning-button class="slds-m-left_large" icon-name="utility:download" label="Download Records" variant="brand" onclick={downloadRecords} disabled={deleteButtonReadOnly}></lightning-button>
</div>
</div>
<div style="height: 18.75rem;">
<lightning-datatable
data={lstRecords}
columns={columns}
key-field="Id"
show-checkbox-column="true"
onrowselection={handleRecordSelection}
sorted-direction={sortDirection}
sorted-by={sortedBy}
onsort={handleSortAccountData}
onrowaction={rowActionHandler}
></lightning-datatable>
</div>
</div>
<template if:true={isModelOpen}>
<section role="dialog" tabindex="-1" aria-labelledby="modal-heading-01" aria-modal="true" class="slds-modal slds-fade-in-open slds-modal_small">
<div class="slds-modal__container slds-is-relative">
<lightning-modal-header label='Delete records'></lightning-modal-header>
<lightning-modal-body>
<div if:false={isLoaded} class="slds-is-relative spinner-margin">
<lightning-spinner alternative-text="Loading..." variant="brand"> </lightning-spinner>
</div>
<div if:true={isLoaded}>
<div if:true={havingRecordsToDelete}>
<p class="slds-align_absolute-center">Delete records</p>
</div>
<div class="slds-modal__content slds-p-around_medium" id="modal-content-id-1">
<div if:true={recordToDelete}>
<lightning-layout multiple-rows>
<template for:each={recordsLst} for:item="record" for:index="index" >
<lightning-layout-item class="slds-truncate_container_60" key={record} size="3">
<lightning-pill data-record-id={record.Id} data-one="value for some" data-two="some value for two" label={record.Name} href={record.recordLink} target="_blank" onremove={removeItem} ></lightning-pill>
</lightning-layout-item>
</template>
</lightning-layout>
</div>
<div if:false={recordToDelete}>
<h1>Please select record to delete.</h1>
</div>
</div>
</div>
</lightning-modal-body>
<lightning-modal-footer>
<lightning-button variant="neutral" label="Cancel" onclick={closeModel}></lightning-button>
<lightning-button class="slds-m-left_x-small" variant="brand" label='Delete' onclick={deleteRecords} disabled={deleteDisabled}></lightning-button>
</lightning-modal-footer>
</div>
</section>
<div class="slds-backdrop slds-backdrop_open" role="presentation"></div>
</template>
</div>
</template> import { LightningElement, wire,track } from 'lwc';
import getRecords from '@salesforce/apex/BulkDeleteController.getRecords';
import getRecordsForLstIds from '@salesforce/apex/BulkDeleteController.getRecordsForLstIds';
import deleteLstRecords from '@salesforce/apex/BulkDeleteController.deleteLstRecords';
import { refreshApex } from '@salesforce/apex';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
import { NavigationMixin } from 'lightning/navigation';
export default class DatatableBulkDelete extends NavigationMixin(LightningElement) {
objectName = 'Account';
lstRecords;
selectRecordLst = [];
updatedselectRecordLst = [];
recordsLst = [];
selectedRows = [];
isModelOpen = false;
isLoaded = false;
recordToDelete = false;
deleteButtonReadOnly = true;
orgUrl;
isRemoveClicked = false;
isAcs = false;
defaultSortDirection = 'asc';
@track sortDirection = 'asc';
sortedBy = 'Name';
@track record = [];
@track recordsCount = 0;
columns = [
{ label: 'Id', fieldName: 'Id', type: 'text' },
{ label: 'Name', fieldName: 'Name', type: 'text',sortable: true },
{ label: 'Account Number', fieldName: 'AccountNumber', type: 'picklist' },
{ label: 'Rating', fieldName: 'Rating', type: 'text' },
{ label: 'Created Date', fieldName: 'CreatedDate', type: 'date' , typeAttributes:{day:'numeric',month:'short',year:'numeric', hour:'2-digit',minute:'2-digit',hour12:true}},
];
headers ={
Id:"Id",
Name:"Name",
AccountNumber:"Account Number",
Rating:"Rating",
CreatedDate:"Created Date",
}
connectedCallback() {
this.orgUrl = window.location.origin;
}
@wire(getRecords, { objectName: 'Account' })
getRecords(result) {
this.wiredRecords = result;
if (result.data) {
if(result.data.length){
this.lstRecords = result.data;
} else{
console.error('Error fetching records', result);
}
} else if (result.error) {
console.error('Error fetching records', result.error);
}
}
handleRecordSelection(event){
const selectedRows = event.detail.selectedRows;
let selectedRecords = [];
this.recordsCount = event.detail.selectedRows.length;
this.selectedRows = selectedRows
for(const row of selectedRows){
selectedRecords.push( row.Id);
}
if(selectedRecords.length) {
this.deleteButtonReadOnly = false;
this.selectRecordLst = selectedRecords.join(',');
} else {
this.deleteButtonReadOnly = true;
}
}
getRecordsForLstIds() {
let lstRecordIds = this.selectRecordLst.split(',');
getRecordsForLstIds({ recordIds: lstRecordIds, objectName: this.objectName })
.then(result => {
if (result) {
this.recordToDelete = true;
result.forEach(item => {
let keys = Object.keys(item);
let nameKey = keys.find(key => key !== 'Id');
if (nameKey) {
this.recordsLst.push({Id: item['Id'],Name: item[nameKey], recordLink: this.orgUrl + '/' + item['Id']
});
this.isLoaded = true;
}
});}
})
.catch(error => {
console.log('Error in getting ids ', error);
});
}
openModel() {
this.isModelOpen = true;
this.getRecordsForLstIds();
}
closeModel() {
this.recordsLst = [];
this.isModelOpen = false;
this.recordToDelete = false;
this.isLoaded = false;
}
deleteRecords() {
let lstRecordIds = this.selectRecordLst.split(',');
if(this.updatedselectRecordLst.length ) {
lstRecordIds = this.updatedselectRecordLst
} else if(this.isRemoveClicked && this.updatedselectRecordLst.length == 0) {
this.showtoast('No record selected');
return;
}
deleteLstRecords({ recordIds: lstRecordIds, objectName: this.objectName })
.then(result => {
if(result == 'success') {
this.showtoast(this.recordsCount + ' records deleted successfully.', 'success');
this.selectRecordLst = [];
this.recordsLst = [];
this.refreshData();
this.deleteButtonReadOnly = true;
this.isModelOpen = false;
this.recordToDelete = false;
} else {
this.deleteButtonReadOnly = true;
this.showtoast('error in deleting records : ' + result, 'error');
}
this.template.querySelector('lightning-datatable').selectedRows = [];
this.recordsCount = 0;
})
.catch(error => {
this.isModelOpen = false;
this.deleteButtonReadOnly = true;
console.log('An error occurred while deleting. ', error)
});
}
refreshData() {
refreshApex(this.wiredRecords);
}
showtoast(message, variant) {
this.dispatchEvent(
new ShowToastEvent({
title: message,
variant: variant
})
);
}
removeItem(event){
const recordId = event.target.dataset.recordId;
let updatedRecordList = this.recordsLst.filter(function(item) {
return item.Id != recordId;
});
this.recordsLst = updatedRecordList;
this.lstRecordIds = updatedRecordList;
this.updatedselectRecordLst = [];
for(let i =0 ; i< this.lstRecordIds.length; i++){
this.updatedselectRecordLst.push(this.lstRecordIds[i].Id);
}
this.isRemoveClicked = true;
}
downloadRecords(){
this.exportCSVFile(this.headers, this.selectedRows, "Record Sheet")
}
exportCSVFile(headers, totalData, fileTitle){
if(!totalData || !totalData.length){
return null
}
const jsonObject = JSON.stringify(totalData)
const result = this.convertToCSV(jsonObject, headers)
if(result === null) return
const blob = new Blob([result])
const exportedFilename = fileTitle ? fileTitle+'.csv' :'export.csv'
if(navigator.msSaveBlob){
navigator.msSaveBlob(blob, exportedFilename)
} else if (navigator.userAgent.match(/iPhone|iPad|iPod/i)){
const link = window.document.createElement('a')
link.href='data:text/csv;charset=utf-8,' + encodeURI(result);
link.target="_blank"
link.download=exportedFilename
link.click()
} else {
const link = document.createElement("a")
if(link.download !== undefined){
const url = URL.createObjectURL(blob)
link.setAttribute("href", url)
link.setAttribute("download", exportedFilename)
link.style.visibility='hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
}
}
convertToCSV(objArray, headers){
const columnDelimiter = ','
const lineDelimiter = 'rn'
const actualHeaderKey = Object.keys(headers)
const headerToShow = Object.values(headers)
let str = ''
str+=headerToShow.join(columnDelimiter)
str+=lineDelimiter
const data = typeof objArray !=='object' ? JSON.parse(objArray):objArray
data.forEach(obj=>{
let line = ''
actualHeaderKey.forEach(key=>{
if(line !=''){
line+=columnDelimiter
}
let strItem = obj[key]+''
line+=strItem? strItem.replace(/,/g, ''):strItem
})
str+=line+lineDelimiter
})
return str
}
handleSortAccountData(event) {
this.sortBy = event.detail.fieldName;
this.sortDirection = event.detail.sortDirection;
this.sortAccountData(event.detail.fieldName, event.detail.sortDirection);
}
sortAccountData(fieldname, sortDirection) {
let parseData = JSON.parse(JSON.stringify(this.lstRecords));
let keyValue = (a) => {
return a[fieldname];
};
let isReverse = sortDirection === 'asc' ? 1: -1;
parseData.sort((x, y) => {
x = keyValue(x) ? keyValue(x) : '';
y = keyValue(y) ? keyValue(y) : '';
if(x > y){
return sortDirection === 'asc' ? -1 : 1;}
else if(y > x){
return sortDirection === 'asc' ? 1 : -1;}
else return 0;
});
this.lstRecords = parseData;
}
rowActionHandler(event) {
const actionName = event.detail.action.name;
const row = event.detail.row;
switch ( actionName ) {
case 'view':
this[NavigationMixin.Navigate]({
type: 'standard__recordPage',
attributes: {
recordId: row.Id,
objectApiName: 'Account',
actionName: 'view'
}
});
break;
case 'edit':
this.refreshData();
this[NavigationMixin.Navigate]({
type: 'standard__recordPage',
attributes: {
recordId: row.Id,
objectApiName: 'Account',
actionName: 'edit'
}
});
break;
default:
}
}
} Building something similar?
Explore more reusable LWC patterns or Apex fundamentals to go with it.