Front End Tables: Sorting, Filtering, and Pagination

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).

sfp5

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.

Table.js
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} />

sfp2

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.

sfp1

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 or 0. Keep it simple. Just use 1 for page 1. 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
Table.js
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.

Pagination.js
const Pagination = ({ activePage, count, rowsPerPage, totalPages, setActivePage }) => {
  return (
    <div className="pagination">
      <button>⏮️ First</button>
      <button>⬅️ Previous</button>
      <button>Next ➡️</button>
      <button>Last ⏭️</button>
    </div>
  )
}

sfp3

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")

Pagination.js
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.

Table.js
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>

sfp4

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 sorted
  • order - 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.)

sfp5

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 👇

View the demo! Break me!

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