Building Your Own Sortable Table Hook
Sortable tables in React don’t require an external library. With the built-in Array.prototype.sort and a bit of state management, you can create a reusable hook that handles sorting for any data set. This walkthrough starts with a basic table component and incrementally adds sorting logic, direction toggling, performance optimizations, and a custom hook—all with familiar React patterns.
Starting with a Static Table
Assume a ProductsTable component that accepts an array of products and renders a row for each. The implementation is straightforward—iterate over the array and output the cells. No sorting is applied yet, but that’s the foundation we’ll build on.
function ProductTable(props) {
const { products } = props;
return (
<table>
<caption>Our products</caption>
<thead>
<tr>
<th>Name</th>
<th>Price</th>
<th>In Stock</th>
</tr>
</thead>
<tbody>
{products.map(product => (
<tr key={product.id}>
<td>{product.name}</td>
<td>{product.price}</td>
<td>{product.stock}</td>
</tr>
))}
</tbody>
</table>
);
}
The Basics of Sorting in JavaScript
JavaScript’s sort() method handles arrays of primitives out of the box, but for objects you supply a comparator. That comparator receives two elements and returns a negative, positive, or zero value to indicate ordering. Sorting by name alphabetically looks like this:
function ProductTable(props) {
const { products } = props;
let sortedProducts = [...products];
sortedProducts.sort((a, b) => {
if (a.name < b.name) {
return -1;
}
if (a.name > b.name) {
return 1;
}
return 0;
});
return (
<Table>
{/* as before */}
</Table>
);
}
Note that sort() mutates the original array. Always create a copy first—here via spread—before running the comparator.
Adding Sortable State
To make the table interactive, track which field is currently being sorted. The useState hook is the natural fit. Initialize it with null to indicate no sorting, then update it via click handlers on the table headers.
const [sortedField, setSortedField] = React.useState(null);
const ProductsTable = (props) => {
const { products } = props;
const [sortedField, setSortedField] = React.useState(null);
return (
<table>
<thead>
<tr>
<th>
<button type="button" onClick={() => setSortedField('name')}>
Name
</button>
</th>
<th>
<button type="button" onClick={() => setSortedField('price')}>
Price
</button>
</th>
<th>
<button type="button" onClick={() => setSortedField('stock')}>
In Stock
</button>
</th>
</tr>
</thead>
{/* As before */}
</table>
);
};
Now the sorting logic needs to be general enough to handle any column. The comparator below checks whether a sort field has been selected, and if so, compares the two products by that field:
const ProductsTable = (props) => {
const { products } = props;
const [sortedField, setSortedField] = React.useState(null);
let sortedProducts = [...products];
if (sortedField !== null) {
sortedProducts.sort((a, b) => {
if (a[sortedField] < b[sortedField]) {
return -1;
}
if (a[sortedField] > b[sortedField]) {
return 1;
}
return 0;
});
}
return (
<table>
Toggling Ascending and Descending
A single click sets the sort field; a second click should flip the direction. This requires richer state than just a field name. Refactor the state to hold an object with both key (the field) and direction—call it sortConfig. The comparator now reads the direction and, for descending, simply flips the sign of the comparison:
sortedProducts.sort((a, b) => {
if (a[sortConfig.key] < b[sortConfig.key]) {
return sortConfig.direction === 'ascending' ? -1 : 1;
}
if (a[sortConfig.key] > b[sortConfig.key]) {
return sortConfig.direction === 'ascending' ? 1 : -1;
}
return 0;
});
The requestSort function handles the state update. If the same field is clicked again, it toggles the direction; otherwise, it starts with ascending order:
const requestSort = key => {
let direction = 'ascending';
if (sortConfig.key === key && sortConfig.direction === 'ascending') {
direction = 'descending';
}
setSortConfig({ key, direction });
}
Update the click handlers on the headers to call requestSort with the appropriate field name.
return (
<table>
<thead>
<tr>
<th>
<button type="button" onClick={() => requestSort('name')}>
Name
</button>
</th>
<th>
<button type="button" onClick={() => requestSort('price')}>
Price
</button>
</th>
<th>
<button type="button" onClick={() => requestSort('stock')}>
In Stock
</button>
</th>
</tr>
</thead>
{/* as before */}
</table>
);
Memoizing the Sort Operation
Sorting all the data on every render is wasteful, especially as the dataset grows. Wrap the sorting step in useMemo so the computation only re-runs when the products, sort key, or direction actually change. This guarantees a new sorted array only when necessary:
const ProductsTable = (props) => {
const { products } = props;
const [sortConfig, setSortConfig] = React.useState(null);
React.useMemo(() => {
let sortedProducts = [...products];
if (sortedField !== null) {
sortedProducts.sort((a, b) => {
if (a[sortConfig.key] < b[sortConfig.key]) {
return sortConfig.direction === 'ascending' ? -1 : 1;
}
if (a[sortConfig.key] > b[sortConfig.key]) {
return sortConfig.direction === 'ascending' ? 1 : -1;
}
return 0;
});
}
return sortedProducts;
}, [products, sortConfig]);
Extracting a Reusable Custom Hook
The benefit of this approach is that the logic can be packaged into a custom hook. Custom hooks are just functions that use other hooks—nothing magical. Move the state and sorting logic into useSortableData, which accepts items and an optional initial sort configuration, and returns the sorted items plus a requestSort function:
const useSortableData = (items, config = null) => {
const [sortConfig, setSortConfig] = React.useState(config);
const sortedItems = React.useMemo(() => {
let sortableItems = [...items];
if (sortConfig !== null) {
sortableItems.sort((a, b) => {
if (a[sortConfig.key] < b[sortConfig.key]) {
return sortConfig.direction === 'ascending' ? -1 : 1;
}
if (a[sortConfig.key] > b[sortConfig.key]) {
return sortConfig.direction === 'ascending' ? 1 : -1;
}
return 0;
});
}
return sortableItems;
}, [items, sortConfig]);
const requestSort = key => {
let direction = 'ascending';
if (sortConfig && sortConfig.key === key && sortConfig.direction === 'ascending') {
direction = 'descending';
}
setSortConfig({ key, direction });
}
return { items: sortedItems, requestSort };
}
The table component then simplifies to a clean, declarative view:
const ProductsTable = (props) => {
const { products } = props;
const { items, requestSort } = useSortableData(products);
return (
<table>{/* ... */}</table>
);
};
Indicating Sort Order Visually
Users need feedback on the current sort state. The hook should also return sortConfig, which can be used to compute style classes or indicators for the active column:
const ProductTable = (props) => {
const { items, requestSort, sortConfig } = useSortableData(props.products);
const getClassNamesFor = (name) => {
if (!sortConfig) {
return;
}
return sortConfig.key === name ? sortConfig.direction : undefined;
};
return (
<table>
<caption>Products</caption>
<thead>
<tr>
<th>
<button
type="button"
onClick={() => requestSort('name')}
className={getClassNamesFor('name')}
>
Name
</button>
</th>
{/* … */}
</tr>
</thead>
{/* … */}
</table>
);
};
With that, you have a fully functional sortable table, no external dependencies—just hooks and standard JavaScript. From modeling the state to optimizing with useMemo, the core patterns here are reusable across any table-driven UI.



