---
title: "lightning-datatable, and when to stop using it"
url: "https://trailassembly.com/lwc-lightning-datatable/"
topic: "LWC and Front-End"
published: "2026-09-18T10:19:48+00:00"
updated: "2026-10-06T13:58:04+00:00"
---

# lightning-datatable, and when to stop using it

**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

```apex
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' }
];
```

```apex
<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.

```apex
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

```apex
{ 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.

```apex
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:

```apex
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](https://trailassembly.com/lwc-accessibility/) reasons the component does not give you.

- Editing spans several fields at once, where `lightning-record-edit-form` is 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](https://trailassembly.com/apex-enforcing-crud-and-fls/)?** Only insofar as your query does. The component renders whatever you hand it.

---

Source: https://trailassembly.com/lwc-lightning-datatable/
Trail Assembly — independent Salesforce write-ups.
