How to Sort, Filter, and Paginate a Table with JavaScript
One thing I've had to do at every job I've had is implement a table on the front end of an application that has sorting, filtering, and pagination.
Sometimes, all that will be implemented on the back end, and I've previously documented how to structure those APIs in REST API: Sorting, Filtering, and Pagination. Other times, the data coming back is guaranteed to be small enough that implementing it all in the back end isn't necessary, but still a good idea on the front end to reduce the amount of DOM nodes rendered to the page at a time.
Initially, I would look up libraries like react-table or Ant Design table and try to ensure they had everything I needed. And that's certainly a viable option, but often the libraries don't match the design and needs of your particular case, and have a lot of features you don't need. Sometimes it's a better option to implement it yourself to have complete flexibility over functionality and design.
So I'm going to demonstrate how to do it using React (but conceptually it can apply to any framework or non-framework).
Prerequisites
- Knowledge of JavaScript, React
Goals
Make a table in React that implements:
- Pagination
- Sorting for strings, Booleans, numbers, and dates (case-insensitive)
- Filtering for strings, Booleans, numbers, and dates (case-insensitive)
We're also not going to implement any styles or use any frameworks to reduce complexity.
And here's a CodeSandbox demo: Click me! I'm the demo!
Getting Started
I'm going to set up some data that includes a string, a number, a Boolean, and a date in the dataset, and enough rows that pagination can be implemented and tested. I'll stick some null data in there as well.
const rows = [
{ id: 1, name: 'Liz Lemon', age: 36, is_manager: true, start_date: '02-28-1999' },
{ id: 2, name: 'Jack Donaghy', age: 40, is_manager: true, start_date: '03-05-1997' },
{ id: 3, name: 'Tracy Morgan', age: 39, is_manager: false, start_date: '07-12-2002' },
{ id: 4, name: 'Jenna Maroney', age: 40, is_manager: false, start_date: '02-28-1999' },
{ id: 5, name: 'Kenneth Parcell', age: Infinity, is_manager: false, start_date: '01-01-1970' },
{ id: 6, name: 'Pete Hornberger', age: null, is_manager: true, start_date: '04-01-2000' },
{ id: 7, name: 'Frank Rossitano', age: 36, is_manager: false, start_date: null },
{ id: 8, name: null, age: null, is_manager: null, start_date: null },
]
We'll also want to define the columns on the table.
const columns = [
{ accessor: 'name', label: 'Name' },
{ accessor: 'age', label: 'Age' },
{ accessor: 'is_manager', label: 'Manager', format: (value) => (value ? '✔️' : '✖️') },
{ accessor: 'start_date', label: 'Start Date' },
]
So now can begin making a table abstraction that loops through the columns for the headers, and accesses the proper data for each row. I also added an optional format
option if you want to display the data differently. It would be a good idea to use it on the date
field.
I prefer to always use brackets and the
return
statement when mapping in React. It makes debugging and editing a lot easier than with implicit returns.
const Table = ({ columns, rows }) => {
return (
<table>
<thead>
<tr>
{columns.map((column) => {
return <th key={column.accessor}>{column.label}</th>
})}
</tr>
</thead>
<tbody>
{rows.map((row) => {
return (
<tr key={row.id}>
{columns.map((column) => {
if (column.format) {
return <td key={column.accessor}>{column.format(row[column.accessor])}</td>
}
return <td key={column.accessor}>{row[column.accessor]}</td>
})}
</tr>
)
})}
</tbody>
</table>
)
}
Note: Including the proper keys in this table is essential. If the keys are not the correct unique values, the table will go crazy.
And I'll pass data into my abstract table component.
<Table rows={rows} columns={columns} />
Now there's a basic table set up, and we can move on to pagination.
Pagination
There are a lot of ways to set up pagination on the front end.
For example, you have Google, which will show you a next button, a previous button if you're past page 1, and a few additional responses to the left and right of the page currently selected.
Personally, I prefer to have "first ️️️⏮️", "previous ⬅️", "next ➡️
", and "last ⏭️" options, so that's the way I'll set it up here.
You should be able to click "first" or "last" to go to the beginning and end, and "previous" and "next" to go back and forth by a single page. If you can't go back or forward anymore, the options should not appear, or should be disabled.
A lot of libraries seem to handle pagination differently, with page 1 being either
1
or0
. Keep it simple. Just use1
for page1
. No need for extra calculations.
In the table, I want to calculate:
- The active page, which is what you'll be updating as you paginate, so it'll go in state
- The count, which is the total number of rows in a front end only table pre-filtering
- The rows per page, which I'm setting to a low number so I can test it all with a small data set, but you can also hold this in state if the user should be able to change it
- The total pages, which will be the total rows divided by rows per page, rounded up
const Table = ({ columns, rows }) => {
const [activePage, setActivePage] = useState(1) const rowsPerPage = 3 const count = rows.length const totalPages = Math.ceil(count / rowsPerPage) const calculatedRows = rows.slice((activePage - 1) * rowsPerPage, activePage * rowsPerPage)
/* ... */
return (
<>
<table>{/* ... */}</table>
<Pagination activePage={activePage} count={count} rowsPerPage={rowsPerPage} totalPages={totalPages} setActivePage={setActivePage} /> </>
)
}
Finally, the calculated rows are the rows that will be displayed in the front end, which ultimately will be affected by filtering, sorting, and paginating. This is the one spot where the page number vs. index needs to be calculate. slice
takes a start
and end
index, so for example on page 3, it would be slice(4, 6)
, showing the 5th and 6th item in the array.
It would be a good idea to memoize the calculated rows.
I'll start getting the pagination set up.
const Pagination = ({ activePage, count, rowsPerPage, totalPages, setActivePage }) => {
return (
<div className="pagination">
<button>⏮️ First</button>
<button>⬅️ Previous</button>
<button>Next ➡️</button>
<button>Last ⏭️</button>
</div>
)
}
Now we just have to do a few calculations. If you're on the first page, there's no "first" or "previous" options, and if you're on the last page, there's no "next" or "last" options.
First and last will always take you to 1
or totalPages
, and previous and next just need to add or remove a page.
Meanwhile, you can show the beginning and the end of the rows displayed (such as "showing rows 20-29")
const Pagination = ({ activePage, count, rowsPerPage, totalPages, setActivePage }) => {
const beginning = activePage === 1 ? 1 : rowsPerPage * (activePage - 1) + 1
const end = activePage === totalPages ? count : beginning + rowsPerPage - 1
return (
<>
<div className="pagination">
<button disabled={activePage === 1} onClick={() => setActivePage(1)}>
⏮️ First
</button>
<button disabled={activePage === 1} onClick={() => setActivePage(activePage - 1)}>
⬅️ Previous
</button>
<button disabled={activePage === totalPages} onClick={() => setActivePage(activePage + 1)}>
Next ➡️
</button>
<button disabled={activePage === totalPages} onClick={() => setActivePage(totalPages)}>
Last ⏭️
</button>
</div>
<p>
Page {activePage} of {totalPages}
</p>
<p>
Rows: {beginning === end ? end : `${beginning} - ${end}`} of {count}
</p>
</>
)
}
That pretty much covers pagination, it should be easy to modify that for any additional design.
Filtering
Next up is filtering. We want to be able to do case insensitive matching of partial strings and numbers, so enn
will match Jenna Maroney
and Kenneth Parcell
.
I'm going to make a search object, so keys and values can be stored for each value being searched on, and therefore multiple searches can be combined.
Every search should reset the pagination back to page one, because pagination will no longer make sense in the middle when the number of entries have changed.
The count
will now be determined by the filteredRows
, since there will be less total items after filtering.
const Table = ({ columns, rows }) => {
const [activePage, setActivePage] = useState(1)
const [filters, setFilters] = useState({}) const rowsPerPage = 3
const filteredRows = filterRows(rows, filters) const calculatedRows = filteredRows.slice( (activePage - 1) * rowsPerPage, activePage * rowsPerPage ) const count = filteredRows.length const totalPages = Math.ceil(count / rowsPerPage)
}
For the filterRows
function, we'll just return the original array if no filters are present, otherwise check if it's a string, Boolean, or number, and to the desired check - I have it checking for partial strings, true
or false
for Booleans, and exact number match (so no 3
for 33
...it even allows for searching on Infinity
, however pointless that may be).
function filterRows(rows, filters) {
if (isEmpty(filters)) return rows
return rows.filter((row) => {
return Object.keys(filters).every((accessor) => {
const value = row[accessor]
const searchValue = filters[accessor]
if (isString(value)) {
return toLower(value).includes(toLower(searchValue))
}
if (isBoolean(value)) {
return (searchValue === 'true' && value) || (searchValue === 'false' && !value)
}
if (isNumber(value)) {
return value == searchValue
}
return false
})
})
}
I made little helper functions for
isString
,isBoolean
, etc - you could use Lodash or whatever else.
Now another row can be added in the headers with a search bar. In a design, this might be populated by clicking on a filter icon, or it can just be displayed in the row as in this example. Although I'm just doing a search bar that requires manual typing for simplicity here, you would probably want to handle each datatype differently - for example, a Boolean could be handled by a dropdown with options for true
, false
, and clear. You might also have a dataset that includes an enum where the values are known, like role
where the options are Writer
, Manager
, Producer
. That could be a dropdown as well instead of making the user type in the values. You could also require only numbers in the number field, and use a date picker for the date field.
Here, if a user types into any search bar, it will add to the list of filters. If a filter is cleared or deleted, it should delete the key. (Leaving the key with a string value of ""
can cause problems with the filtering for numbers, Booleans, etc. if you don't handle that case).
The search function:
const handleSearch = (value, accessor) => {
setActivePage(1)
if (value) {
setFilters((prevFilters) => ({
...prevFilters,
[accessor]: value,
}))
} else {
setFilters((prevFilters) => {
const updatedFilters = { ...prevFilters }
delete updatedFilters[accessor]
return updatedFilters
})
}
}
In the column header:
<thead>
<tr>{/* ... */}</tr>
<tr>
{columns.map((column) => {
return (
<th>
<input
key={`${column.accessor}-search`}
type="search"
placeholder={`Search ${column.label}`}
value={filters[column.accessor]}
onChange={(event) => handleSearch(event.target.value, column.accessor)}
/>
</th>
)
})}
</tr>
</thead>
Now filters are set up, and should handle adding and removing values for all the data types, as well as ignoring null
and undefined
values.
Sorting
With sorting, we want to be able to do three things for each column:
- Sort ascending (⬆️)
- Sort descending (⬇️)
- Reset sort/no sort (↕️)
Here's a little table, because I often forget how ascending and descending apply to different types of data.
Ascending vs. Descending
Type | Order | Example | Description |
---|---|---|---|
Alphabetical | Ascending | A - Z |
First to last |
Alphabetical | Descending | Z - A |
Last to first |
Numerical | Ascending | 1 - 9 |
Lowest to highest |
Numerical | Descending | 9 - 1 |
Highest to lowest |
Date | Ascending | 01-01-1970 - Today |
Oldest to newest |
Date | Descending | Today - 01-01-1970 |
Newest to oldest |
Unlike filtering, I'm only going to set up the sort to sort one column at a time. Multi sort might be an option on some tables, but it would be a challenge determining which ones take precedence and how to display it, so I'm going to stick with single sort, which will reset the sort with each new column.
We need to hold two pieces of data in state for sorting:
orderBy
- which column is being sortedorder
- whether it's ascending or descending
const Table = ({ columns, rows }) => {
const [activePage, setActivePage] = useState(1)
const [filters, setFilters] = useState({})
const [sort, setSort] = useState({ order: 'asc', orderBy: 'id' }) // ...
}
I'm setting the default accessor to id
because I know that's the main key here, but realistically you'd want to pass in a variable into the table to determine the column index.
Once again, we need to check for the types and sort accordingly on the rows - we can do this after filtering them. I'm using the built in localeCompare
function, which can handle strings, numbers, and date strings.
function sortRows(rows, sort) {
return rows.sort((a, b) => {
const { order, orderBy } = sort
if (isNil(a[orderBy])) return 1
if (isNil(b[orderBy])) return -1
const aLocale = convertType(a[orderBy])
const bLocale = convertType(b[orderBy])
if (order === 'asc') {
return aLocale.localeCompare(bLocale, 'en', { numeric: isNumber(b[orderBy]) })
} else {
return bLocale.localeCompare(aLocale, 'en', { numeric: isNumber(a[orderBy]) })
}
})
}
The sort function will again restart the pagination, and set the accessor and sort order. If it's already descending, we want to set it to ascending, and so on.
const handleSort = (accessor) => {
setActivePage(1)
setSort((prevSort) => ({
order: prevSort.order === 'asc' && prevSort.orderBy === accessor ? 'desc' : 'asc',
orderBy: accessor,
}))
}
In the main table header, I'm adding the sort button next to the column label. Some designs will choose to display the current state of ascending vs. descending, and some will choose to show what it will be after you press the button. I went with showing the current state.
<thead>
<tr>
{columns.map((column) => {
const sortIcon = () => {
if (column.accessor === sort.orderBy) {
if (sort.order === 'asc') {
return '⬆️'
}
return '⬇️'
} else {
return '️↕️'
}
}
return (
<th key={column.accessor}>
<span>{column.label}</span>
<button onClick={() => handleSort(column.accessor)}>{sortIcon()}</button>
</th>
)
})}
</tr>
<tr>{/* ... */}</tr>
</thead>
Now in the table, you'll filter, then sort, then paginate.
export const Table = ({ columns, rows }) => {
const [activePage, setActivePage] = useState(1)
const [filters, setFilters] = useState({})
const [sort, setSort] = useState({ order: 'asc', orderBy: 'id' })
const rowsPerPage = 3
const filteredRows = useMemo(() => filterRows(rows, filters), [rows, filters]) const sortedRows = useMemo(() => sortRows(filteredRows, sort), [filteredRows, sort]) const calculatedRows = paginateRows(sortedRows, activePage, rowsPerPage)
// ...
}
I added in useMemo
here to make it more performant and so nobody yells at me. (Would need to add a deeper comparison for pagination to be memoized on a sorted row, though.)
And now, the table is ready! You can sort, you can paginate, you can filter, oh my!
Conclusion
Success! We have a table in React implementing sorting, filtering, and pagination without using any libraries. It's ugly as sin but since we know how it all works, we know how to improve it, make it harder, better, faster, stronger.
A few possible improvements:
- Update the search bar for Boolean types to be a dropdown or checkbox/switch
- Update the search bar for date types to be a datepicker
- Implement range search for numbers and dates
- Implement sorting and filtering for arrays (for example, a list of tags)
- Find whatever edge my lazy ass didn't test for and fix it
And don't forget to play around with it and try to break it 👇
Please don't @ me about having to manually type in "true" for Booleans, or manually type in dates instead of using a date picker, or having exact number search instead of date range, etc. That's your homework! Fix it!
And remember, if you're doing all this work on the back end because the data sets are large, you can look at REST API: Sorting, Filtering, and Pagination. Then the code in the table will make API calls instead of handling the arrays and you'll just show a loading state in between each search.
Comments