clubmate.fi

A good[ish] website

Web development blog, loads of UI and JavaScript topics

How to make a sortable data table using React

Filed under: UI components— Tagged with: React, sorting

Make your data tables pop more, by making the data in them more friendly by means of sortable columns, all the while keeping things accessible and responsive.

How the table sorting actually works is: sort the data that makes the table, and let React re-render the table with the newly sorted data.

The demo table and the data

The very ordinary looking demo table: <SortableTable />, which renders random things in random order:

List of random things
Name Description
SockClothing item
TubeTopological shape
ChairSitting device
Aluminum sidingBuilding material
CleanexTissue
Jade buddhaTrinket
KnobAdjective
TangerineFruit

Here’s the data we’re using:

const randomThings = [
  { name: 'Sock', description: 'Clothing item' },
  { name: 'Tube', description: 'Topological shape' },
  { name: 'Chair', description: 'Sitting device' },
  { name: 'Aluminum siding', description: 'Building material' },
  { name: 'Cleanex', description: 'Tissue' },
  { name: 'Jade buddha', description: 'Trinket' },
  { name: 'Knob', description: 'Adjective' },
  { name: 'Tangerine', description: 'Fruit' }
]

And here’s the component itself:

const SortableTable = () => (
  <table>
    <caption align="bottom">List of random things</caption>
    <thead>
      <tr>
        <th>Name</th>
        <th>Description</th>
      </tr>
    </thead>
    <tbody>
      {tableData.map(thing => (
        <tr key={thing.name}>
          <td>{thing.name}</td>
          <td>{thing.description}</td>
        </tr>
      ))}
    </tbody>
  </table>
)

Nothing surprising so far.

Sorting the data with sort

JavaScript has Array.prototype.sort(), here’s a brief reminder on how that works.

How to use the native JavaScript sort method

By default sort will sort the array alphabetically, all non undefined values are converted into strings. No need to use the compare function:

const things = ['Zumba', 'Aardvark']
const numbers = [9, 1, 100]
console.log(numbers.sort(), things.sort())
// [1, 100, 9] ['Aardvark', 'Zumba']

The default behavior is great for simple use-cases, but we can immediately see few limitations:

  • 100 comes before 9, that’s because the sort is alphabetical, not numeric.
  • It only sorts in ascending order (the result can be run through reverse() though).
  • This will not work with arrays of objects, like in our test data.

The native sort method runs the compare function under the hood, the first attribute in sort function:

things.sort((a, b) => {
  if (a < b) return -1
  if (a > b) return 1
  return 0
})

Here’s how that compareFunction works:

  • If compareFunction(a, b) returns less than 0, sort a to an index lower than b (i.e. a comes first).
  • If compareFunction(a, b) returns 0, leave a and b unchanged with respect to each other, but sorted with respect to all different elements.
  • If compareFunction(a, b) returns greater than 0, sort b to an index lower than a (i.e. b comes first).

Sorting the table data

Now that we know how sort works, we can build a simple sorting helper function. It takes two params: the array to be sorted, and the key that we want to sort the data by:

const sortTableData = (array, sortBy) => {
  return array.sort((a, b) => {
    if (a[sortBy] < b[sortBy]) return -1
    if (a[sortBy] > b[sortBy]) return 1
    return 0
  })
}

const things = [
  { name: 'Zumba', description: 'Sport' },
  { name: 'Aardvark', description: 'Animal' }
]

console.log(sortTableData(things, 'name'))
// { name: 'Aardvark', description: 'Animal' }

An elementary feature for a sorting method is its ability change direction: ascending vs descending. Ascending being the default in this case:

const sortTableData = (array, { sortBy, direction }) => {
  return array.sort((a, b) => {
    if (a[sortBy] < b[sortBy]) return direction === 'ascending' ? -1 : 1
    if (a[sortBy] > b[sortBy]) return direction === 'ascending' ? 1 : -1
    return 0
  })
}

console.log(sortTableData(things, { sortBy: 'name', direction: 'descending' }))

Let’s bake that into our demo component, it now takes sortConfig as a prop:

<SortableTable sortConfig={{ sortBy: 'name', direction: 'ascending' }} />

Now it renders a table sorted by the "Name" column:

List of random things
NameDescription
Aluminum sidingBuilding material
ChairSitting device
CleanexTissue
Jade buddhaTrinket
KnobAdjective
SockClothing item
TangerineFruit
TubeTopological shape

The innards of the table component look like this (it’s using the above sortTableData helper):

import sortTableData from '../helpers/sortTableData'
import things from './things'

const SortableTable = ({ sortConfig }) => {
  const shouldSort = sortConfig?.sortBy
  const tableData = shouldSort ? sortTableData(things, sortConfig) : things

  return (
    <table>
      <caption align="bottom">List of random things</caption>
      <thead>
        <tr>
          <th>Name</th>
          <th>Description</th>
        </tr>
      </thead>
      <tbody>
        {tableData.map(thing => (
          <tr key={thing.name}>
            <td>{thing.name}</td>
            <td>{thing.description}</td>
          </tr>
        ))}
      </tbody>
    </table>
  )
}

That works, but it’s very rudimentary; only one row can be chosen to be sorted, and there’s no sorting UI.

Next, let’s build a proper sorting UI.

Make the individual table rows sortable

Here’s how the sortable table should look like:

List of random things
Name Description
Aluminum sidingBuilding material
ChairSitting device
CleanexTissue
Jade buddhaTrinket
KnobAdjective
SockClothing item
TangerineFruit
TubeTopological shape

The sort button

As sort button icons I’m using some Unicode arrows (in production code with actual design requirements, you’d probably use a some nice SVG icons carefully crafted by your team’s designer):

  • ↕︎ = sorting is available
  • ↓ = ascending sort
  • ↑ = descending sort

The component that holds the arrow has some accessibility requirements:

  • It should be a button, since that’s the semantically correct element to use in a case like this.
  • The buttons should hold screen-reader-only text, which reads out the functionality of the button, i.e.: "Sort row ascending".
  • It shouldn’t look like a button. The button can be reset to look like text.

Here’s the component that renders the sort button:

import { BlankButton, VisuallyHidden } from './styles'

const SortButton = ({ direction, id, onClick, sortBy }) => {
  const arrows = { ascending: '↓', descending: '↑' }
  const arrow = sortBy === id ? arrows[direction] : '↕︎'

  return (
    <BlankButton id={id} onClick={onClick}>
      {arrow}
      <VisuallyHidden>Sort {direction}</VisuallyHidden>
    </BlankButton>
  )
}

The component now looks something like this:

const SortableTable = ({ sortConfig }) => {
  const [sortedItems, setSortedItems] = React.useState(things)
  const [direction, setDirection] = React.useState()
  const [sortBy, setSortBy] = React.useState()
  const { showSortUi } = sortConfig || {}

  const handleClick = event => {
    const sortDir = direction === 'descending' ? 'ascending' : 'descending'
    setDirection(sortDir)
    setSortBy(event.target.id)
    const sortConfig = { sortBy: event.target.id, direction: sortDir }
    setSortedItems(sortTableData(things, sortConfig))
  }

  return (
    <table>
      <caption align="bottom">List of random things</caption>
      <thead>
        <tr>
          <th>
            Name{' '}
            {showSortUi && (
              <SortButton
                direction={direction}
                id="name"
                onClick={handleClick}
                sortBy={sortBy}
              />
            )}
          </th>
          <th>
            Description{' '}
            {showSortUi && (
              <SortButton
                direction={direction}
                id="description"
                onClick={handleClick}
                sortBy={sortBy}
              />
            )}
          </th>
        </tr>
      </thead>
      <tbody>
        {sortedItems.map(thing => (
          <tr key={thing.name}>
            <td>{thing.name}</td>
            <td>{thing.description}</td>
          </tr>
        ))}
      </tbody>
    </table>
  )
}

That might work for you, but if you find yourself reusing it a lot, might come handy to write a React hook.

A sort React hook

As I was researching this article I came across a Smashing Magazine article on the same topic and it had brilliant solution baked into a hook. I’m going to use a slightly modified version of that.

Here’s how the useSortableData hooks might look when used in a React component:

const { items, requestSort, sortConfig } = useSortableData(data, {
  direction: 'ascending',
  key: 'name'
})

Parameters:

data array
The data you want to sort.
[config] object
The optional config object defines the initial sort, if you want to use the original order when the table loads, then skip the config completely.

And the return value:

items array
The sorted data.
requestSort() fn
This method is used to trigger the sort "on demand", you call it when an onClick event happens in a sort button etc.
key string
The object key to sort by.
sortConfig object
This object contains the current direction of the sort and the key that is being sorted. i.e.: { key: 'name', direction: 'ascending' }

And the code for the hook itself, scroll down to see how it’s used:

import React from 'react'

/**
 * Sorts an array og objects, either in ascending or descending order, based on
 * the given key.
 *
 * @param {array} array - The array to sort.
 * @param {object} config - The configuration object.
 * @param {string} config.key - The object key to sort by.
 * @param {(ascending|descending)} [config.direction] - The sort direction.
 * @returns {array}
 */
const sortTableData = (array, { key, direction }) => {
  return array.sort((a, b) => {
    if (a[key] < b[key]) return direction === 'ascending' ? -1 : 1
    if (a[key] > b[key]) return direction === 'ascending' ? 1 : -1

    return 0
  })
}

/**
 * A React hook that will sort a given array. The configuration object can
 * define the initial sort order and key, if none given, no sort is done.
 *
 * @param {array} items - The data that needs to be sorted.
 * @param {object} [config] - A configuration object.
 * @param {string} [config.key] - Name of key to sort by.
 * @param {(ascending|descending)} [config.direction] - The sort direction.
 * @returns {object}
 */
const useSortableData = (items = [], config) => {
  const [sortConfig, setSortConfig] = React.useState(config)

  const sortedItems = React.useMemo(() => {
    // If no config was defined then return the unsorted array
    if (!sortConfig) return items

    return sortTableData(items, { ...sortConfig })
  }, [items, sortConfig])

  const requestSort = key => {
    let direction = 'descending'

    if (
      sortConfig &&
      sortConfig.key === key &&
      sortConfig?.direction === 'descending'
    ) {
      direction = 'ascending'
    }

    setSortConfig({ key, direction })
  }

  return { items: sortedItems, requestSort, sortConfig }
}

export default useSortableData

This is how you'd use the useSortableData hook to make a table:

import React from 'react'
import PropTypes from 'prop-types'
import VisuallyHidden from '../src/components/VisuallyHidden'
import useSortableData from '../src/hooks/useSortableData'
import { BlankButton } from './styles'

const SortButton = ({ direction, id, onClick, sortBy }) => {
  const arrows = { ascending: '↓', descending: '↑' }
  const arrow = sortBy === id ? arrows[direction] : '↕︎'

  return (
    <BlankButton id={id} onClick={onClick}>
      {arrow}
      <VisuallyHidden>Sort {direction}</VisuallyHidden>
    </BlankButton>
  )
}

const SortableTableWithHook = props => {
  const { items, requestSort, sortConfig } = useSortableData(
    randomThings,
    props.config
  )

  return (
    <table>
      <caption align="bottom">List of random things</caption>
      <thead>
        <tr>
          <th>
            Name{' '}
            <SortButton
              direction={sortConfig?.direction}
              id="name"
              onClick={() => requestSort('name')}
              sortBy={sortConfig?.key}
            />
          </th>
          <th>
            Description{' '}
            <SortButton
              direction={sortConfig?.direction}
              id="description"
              onClick={() => requestSort('description')}
              sortBy={sortConfig?.key}
            />
          </th>
        </tr>
      </thead>
      <tbody>
        {items.map(thing => (
          <tr key={thing.name}>
            <td>{thing.name}</td>
            <td>{thing.description}</td>
          </tr>
        ))}
      </tbody>
    </table>
  )
}

export default SortableTableWithHook

Conclusions

Remember to use <caption> with tables, that’s good for accessibility.

Comments would go here, but the commenting system isn’t ready yet, sorry.

  • © 2022 Antti Hiljá
  • About
  • All rights reserved yadda yadda.
  • I can put just about anything here, no one reads the footer anyways.
  • I love u!