The quick version: lightning-datatable gives you a styled, accessible, virtually scrolling grid for the price of a columns array and a key-field. It does not sort your data, it does not save your edits, and it does not fetch the next page. Those are all handed back to you as events. The moment you find yourself fighting it to render something it has no cell type for, you have passed the point where a hand-built list would have been less work.
The minimum
const COLUMNS = [
{ label: 'Name', fieldName: 'Name', type: 'text' },
{ label: 'Amount', fieldName: 'Amount', type: 'currency',
typeAttributes: { currencyCode: 'GBP' } },
{ label: 'Close', fieldName: 'CloseDate', type: 'date' },
{ label: 'Owner', fieldName: 'ownerName', type: 'text' }
];
<lightning-datatable
key-field="Id"
data={opportunities}
columns={columns}
hide-checkbox-column>
</lightning-datatable>
key-field is required and identifies each row. Row selection, editing and actions all report back using that value.
Two things the data shape forces on you. Spanning fields from SOQL arrive nested, so Owner.Name is not addressable as a fieldName. Flatten it in JavaScript before you assign the array. And a column of type url needs the link text supplied separately through typeAttributes.label, which usually means adding a second property to each row as well.
Sorting, which it does not do
Set sortable: true on a column and the header becomes clickable. That is the extent of it. The component fires onsort with the field and direction, and you sort the array yourself and reassign it, along with sorted-by and sorted-direction so the arrow points the right way.
handleSort(event) {
const { fieldName, sortDirection } = event.detail;
const factor = sortDirection === 'asc' ? 1 : -1;
this.rows = [...this.rows].sort((a, b) =>
factor * String(a[fieldName]).localeCompare(String(b[fieldName])));
this.sortedBy = fieldName;
this.sortedDirection = sortDirection;
}
Sorting the array in place without reassigning it will not re-render. Copy first.
Row actions
{ type: 'action', typeAttributes: { rowActions: getRowActions } }
rowActions accepts either a static array or a function that receives the row and a callback, which is how you show different actions per row based on its state. The onrowaction handler gets event.detail.action.name and event.detail.row.
A button column is the alternative when the action should be visible rather than hidden behind a menu. It costs horizontal space and is clearer for the one action people use constantly.
Inline editing
Add editable: true to a column and cells gain a pencil. The component tracks draft values and shows a Save and Cancel bar. It does not write anything to the database.
async handleSave(event) {
const records = event.detail.draftValues.map((draft) => ({ fields: { ...draft } }));
try {
await Promise.all(records.map((r) => updateRecord(r)));
this.draftValues = [];
await refreshApex(this.wiredResult);
} catch (error) {
this.errors = reduceErrors(error);
}
}
Three details decide whether this works. The draft objects carry Id because key-field is Id, which is what updateRecord needs. You must clear draftValues yourself after a successful save, or the edit bar stays on screen. And you must refresh the underlying data, because updating through Apex leaves the wire cache holding the old values.
Custom cell types
When the built-in types are not enough, create a component that extends LightningDatatable and declare the type:
import LightningDatatable from 'lightning/datatable';
import pillTemplate from './pill.html';
export default class CustomDatatable extends LightningDatatable {
static customTypes = {
statusPill: { template: pillTemplate, typeAttributes: ['status'] }
};
}
Each custom type needs its own HTML file, and the template receives the cell value plus whatever you list in typeAttributes. It works well for badges, icons and formatted output. It works poorly for anything interactive, because events raised inside a custom cell have to climb out of the datatable’s own shadow tree and the wiring is awkward.
When to stop using it
Give it up when:
- More than one or two columns need a custom type, particularly interactive ones.
- The layout is not really a grid. Cards, grouped sections and expandable detail rows all fight the component.
- You need control over markup for accessibility reasons the component does not give you.
- Editing spans several fields at once, where
lightning-record-edit-formis a better fit.
Also consider lightning-tree-grid for hierarchical data, which is the same component family with expandable rows. Before abandoning the datatable entirely, remember what you lose: keyboard navigation across cells, virtual scrolling that keeps large row counts usable, column resizing, and styling that stays correct when the platform theme changes.
Frequently asked
Why is my nested field blank? fieldName does not traverse relationships. Flatten Owner.Name into a top-level property first.
How do I do server-side paging? Use enable-infinite-loading with onloadmore, and append to the data array as each page arrives.
Does it respect field-level security? Only insofar as your query does. The component renders whatever you hand it.
