Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/api/features/sorting.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ sortUndefined?: 'first' | 'last' | false | -1 | 1 // defaults to 1

> NOTE: `'first'` and `'last'` options are new in v8.16.0

> NOTE: `sortUndefined` only affects undefined values, not null. For handling both null and undefined values (Excel-like sorting), see the [Excel-like Sorting Guide](../guide/excel-like-sorting.md).

## Column API

### `getAutoSortingFn`
Expand Down
201 changes: 201 additions & 0 deletions docs/guide/excel-like-sorting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
---
title: Excel-like Sorting
---

# Excel-like Sorting with Null/Undefined Values

Excel and other spreadsheet applications handle empty cells in a specific way during sorting - they always appear at the bottom regardless of sort direction. This guide shows how to achieve the same behavior in TanStack Table.

## The Challenge

By default, JavaScript's sorting behavior for null/undefined values can be inconsistent. The `sortUndefined` option only handles undefined values, not null. To achieve true Excel-like sorting, we need a custom approach.

## Solution

### Step 1: Create a Custom Sorting Function

```tsx
const excelLikeSortingFn = (rowA, rowB, columnId) => {
const a = rowA.getValue(columnId);
const b = rowB.getValue(columnId);

// Check for empty values (null, undefined)
const aEmpty = a == null;
const bEmpty = b == null;

// If both are empty, they're equal
if (aEmpty && bEmpty) return 0;

// Empty values always go to bottom
if (aEmpty) return 1;
if (bEmpty) return -1;

// Normal comparison for non-empty values
return a < b ? -1 : a > b ? 1 : 0;
};
```
Comment on lines +17 to +36
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Custom sortingFn cannot keep nulls “always last” in both directions

Table desc sorting inverts the comparator result, so this function will place null/undefined at the top when sorting desc. It contradicts the stated “always at the bottom regardless of sort direction.” Use sortUndefined: 'last' and normalize null→undefined instead of relying on a comparator to enforce direction-agnostic placement.

Apply this diff to mark the function as optional and clarify intent:

-### Step 1: Create a Custom Sorting Function
+### Step 1 (Optional): Custom Sorting Function
@@
-  // Empty values always go to bottom
+  // NOTE: This keeps empties last for ascending only.
+  // For "always last" in both directions, prefer normalizing to `undefined`
+  // and using `sortUndefined: 'last'` (see next step).
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```tsx
const excelLikeSortingFn = (rowA, rowB, columnId) => {
const a = rowA.getValue(columnId);
const b = rowB.getValue(columnId);
// Check for empty values (null, undefined)
const aEmpty = a == null;
const bEmpty = b == null;
// If both are empty, they're equal
if (aEmpty && bEmpty) return 0;
// Empty values always go to bottom
if (aEmpty) return 1;
if (bEmpty) return -1;
// Normal comparison for non-empty values
return a < b ? -1 : a > b ? 1 : 0;
};
```
### Step 1 (Optional): Custom Sorting Function
🤖 Prompt for AI Agents
In docs/guide/excel-like-sorting.md around lines 17 to 36, the provided
comparator attempts to force null/undefined always last but table descending
sort inverts comparator results so it fails; update the docs to mark the custom
sortingFn as optional, remove the claim that the comparator alone will keep
nulls last in both directions, and instead show/describe normalizing
null→undefined and using the built-in option sortUndefined: 'last' for
direction-agnostic placement; keep the comparator example only as an optional
fallback for custom value comparison (not for controlling undefined ordering).


### Step 2: Apply to Your Columns

```tsx
const columns = [
{
id: 'price',
accessorFn: row => row.price ?? null,
header: 'Price',
cell: ({ getValue }) => {
const value = getValue();
return value == null ? '-' : `$${value}`;
},
sortingFn: excelLikeSortingFn,
sortUndefined: 'last'
}
];
Comment on lines +41 to +53
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Normalize null→undefined and drop sortingFn

To keep empty values at the bottom for both asc/desc, normalize null to undefined and rely on sortUndefined: 'last'. Current code does the opposite (undefined→null), making sortUndefined ineffective for those values.

 const columns = [
   {
     id: 'price',
-    accessorFn: row => row.price ?? null,
+    accessorFn: row => (row.price == null ? undefined : row.price),
     header: 'Price',
     cell: ({ getValue }) => {
       const value = getValue();
-      return value == null ? '-' : `$${value}`;
+      return value == null ? '-' : `$${value}`;
     },
-    sortingFn: excelLikeSortingFn,
     sortUndefined: 'last'
   }
 ];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const columns = [
{
id: 'price',
accessorFn: row => row.price ?? null,
header: 'Price',
cell: ({ getValue }) => {
const value = getValue();
return value == null ? '-' : `$${value}`;
},
sortingFn: excelLikeSortingFn,
sortUndefined: 'last'
}
];
const columns = [
{
id: 'price',
accessorFn: row => (row.price == null ? undefined : row.price),
header: 'Price',
cell: ({ getValue }) => {
const value = getValue();
return value == null ? '-' : `$${value}`;
},
sortUndefined: 'last'
}
];
🤖 Prompt for AI Agents
In docs/guide/excel-like-sorting.md around lines 41 to 53, the accessor
currently converts undefined to null and also sets a custom sortingFn, which
prevents the built-in sortUndefined: 'last' behavior from working; change the
accessorFn to normalize empty values to undefined (e.g., return row.price ??
undefined) and remove the sortingFn property so the column relies on
sortUndefined: 'last' to keep empty values at the bottom for both ascending and
descending sorts.

```

### Step 3: Global Configuration (Optional)

Register the sorting function globally for reuse:

```tsx
const table = useReactTable({
data,
columns,
sortingFns: {
excelLike: excelLikeSortingFn
},
defaultColumn: {
sortingFn: 'excelLike'
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel()
});
```

## Complete Example

```tsx
import React from 'react';
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
flexRender
} from '@tanstack/react-table';

// Sample data with null/undefined values
const data = [
{ id: 1, product: 'Laptop', price: 999, stock: 10 },
{ id: 2, product: 'Mouse', price: 25, stock: null },
{ id: 3, product: 'Keyboard', price: null, stock: 5 },
{ id: 4, product: 'Monitor', price: 399, stock: undefined },
{ id: 5, product: 'Headphones', price: 89, stock: 0 }
];

function ExcelSortingTable() {
// Excel-like sorting function
const excelLikeSortingFn = (rowA, rowB, columnId) => {
const a = rowA.getValue(columnId);
const b = rowB.getValue(columnId);

if (a == null && b == null) return 0;
if (a == null) return 1;
if (b == null) return -1;

return a < b ? -1 : a > b ? 1 : 0;
};

Comment on lines +95 to +107
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Remove the per-example sortingFn; rely on normalization + sortUndefined

The inline excelLikeSortingFn still fails “always last” for nulls on desc. Simplify by removing it.

 function ExcelSortingTable() {
-  // Excel-like sorting function
-  const excelLikeSortingFn = (rowA, rowB, columnId) => {
-    const a = rowA.getValue(columnId);
-    const b = rowB.getValue(columnId);
-    
-    if (a == null && b == null) return 0;
-    if (a == null) return 1;
-    if (b == null) return -1;
-    
-    return a < b ? -1 : a > b ? 1 : 0;
-  };
+  // Normalize null to undefined and use sortUndefined: 'last' per column.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In docs/guide/excel-like-sorting.md around lines 95 to 107, the inline
excelLikeSortingFn forces nulls to behave incorrectly (they remain "always last"
on descending sorts); remove this per-example sortingFn entirely and rely on the
existing value normalization plus the table/sort utility's sortUndefined
handling instead—delete the custom comparator and update the example to
demonstrate sorting using normalized values and sortUndefined configuration so
null/undefined are handled correctly by the shared sort logic.

const columns = React.useMemo(
() => [
{
accessorKey: 'product',
header: 'Product'
},
{
id: 'price',
accessorFn: row => row.price ?? null,
header: 'Price',
cell: ({ getValue }) => {
const value = getValue();
return value == null ? '-' : `$${value}`;
},
sortingFn: excelLikeSortingFn,
sortUndefined: 'last'
},
{
id: 'stock',
accessorFn: row => row.stock ?? null,
header: 'Stock',
cell: ({ getValue }) => {
const value = getValue();
return value == null ? 'N/A' : value;
},
sortingFn: excelLikeSortingFn,
sortUndefined: 'last'
}
Comment on lines +115 to +135
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix column definitions to achieve “always last”

Normalize null→undefined and remove sortingFn from both columns. Keep sortUndefined: 'last'.

       {
         id: 'price',
-        accessorFn: row => row.price ?? null,
+        accessorFn: row => (row.price == null ? undefined : row.price),
         header: 'Price',
         cell: ({ getValue }) => {
           const value = getValue();
           return value == null ? '-' : `$${value}`;
         },
-        sortingFn: excelLikeSortingFn,
         sortUndefined: 'last'
       },
       {
         id: 'stock',
-        accessorFn: row => row.stock ?? null,
+        accessorFn: row => (row.stock == null ? undefined : row.stock),
         header: 'Stock',
         cell: ({ getValue }) => {
           const value = getValue();
           return value == null ? 'N/A' : value;
         },
-        sortingFn: excelLikeSortingFn,
         sortUndefined: 'last'
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
id: 'price',
accessorFn: row => row.price ?? null,
header: 'Price',
cell: ({ getValue }) => {
const value = getValue();
return value == null ? '-' : `$${value}`;
},
sortingFn: excelLikeSortingFn,
sortUndefined: 'last'
},
{
id: 'stock',
accessorFn: row => row.stock ?? null,
header: 'Stock',
cell: ({ getValue }) => {
const value = getValue();
return value == null ? 'N/A' : value;
},
sortingFn: excelLikeSortingFn,
sortUndefined: 'last'
}
{
id: 'price',
accessorFn: row => (row.price == null ? undefined : row.price),
header: 'Price',
cell: ({ getValue }) => {
const value = getValue();
return value == null ? '-' : `$${value}`;
},
sortUndefined: 'last'
},
{
id: 'stock',
accessorFn: row => (row.stock == null ? undefined : row.stock),
header: 'Stock',
cell: ({ getValue }) => {
const value = getValue();
return value == null ? 'N/A' : value;
},
sortUndefined: 'last'
}
🤖 Prompt for AI Agents
In docs/guide/excel-like-sorting.md around lines 115 to 135, the price and stock
column defs currently normalize missing values to null and explicitly set
sortingFn; update both columns to normalize missing values to undefined (e.g.
change accessorFn from row => row.price ?? null to row => row.price ?? undefined
and similarly for stock) and remove the sortingFn property from both column
objects while keeping sortUndefined: 'last'.

],
[]
);

const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel()
});

return (
<table>
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th
key={header.id}
onClick={header.column.getToggleSortingHandler()}
style={{ cursor: 'pointer' }}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{{
asc: ' 🔼',
desc: ' 🔽',
}[header.column.getIsSorted()] ?? null}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id}>
{row.getVisibleCells().map(cell => (
<td key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}

export default ExcelSortingTable;
```

## Key Points

- `sortUndefined: 'last'` only handles undefined values, not null
- Custom `sortingFn` is required for consistent null/undefined handling
- `accessorFn` with `?? null` normalizes undefined to null
- `cell` function controls display of empty values

## Credits

This solution was contributed by the community in Issue #6061.