LWC Datatable with Bulk Delete & Export to CSV

Lightning Web Components

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.

TypeLWC + Apex
Files3
Object UsedAccount
DemoVideo

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.

1

Select rows

The datatable's built-in checkbox column captures selected record Ids via the onrowselection handler -- no custom selection logic needed.

2

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.

3

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.

4

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.

APEXBulkDeleteController.cls
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();
        }
    }
}
HTMLdatatableBulkDelete.html
<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>
JSdatatableBulkDelete.js
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.

Leave a Reply