Ibadehin Mojeed I'm an advocate of project-based learning. I also write technical content around web development.

Creating a React sortable table

14 min read 3956

Creating A React Sortable Table

Sometimes when we create a table to present data in our application, we may want to add a sorting functionality for data management.

While there are libraries like the React Table that allow us to add a sorting functionality and more, sometimes using libraries is not the best fit, especially if we want a simple table with total flexibility.

In this tutorial, we will cover how to create a sortable table with React from scratch. We will sort table rows in ascending or descending order by clicking on the table headers.

We’ll also add functionality that allows a default sorting by a specific column, and we’ll build a reusable, sortable table component.

Finally, we’ll learn how to properly use the JavaScript sort() function and some important React principles. In the end, we will have a working sortable table that looks like the image below. You can interact with the project and after that, get started!

React Sortable Table Final Project

To follow this tutorial, you must have a working knowledge of React.

Jump ahead:

Creating the table markup in React

Let’s start by creating a React project with create-react-app and start the development server. Once the project is up and running, we will create the table markup.

Recall from HTML, the table markup follows the following structure that includes the table caption:



<table>
  <caption>Caption here</caption>
  <thead>
    <tr>
      <th>{/* ... */}</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>{/* ... */}</td>
    </tr>
  </tbody>
</table>

Since React is a component-based library, we can split the markup into different component files. The Table component will serve as the parent component holding the TableHead and TableBody components.

Inside these children components, we will render the table heading and the body contents respectively. And finally, we will render the parent component inside the App component.

In the src folder, let’s create the files so we have the following structure:

react-sortable-table
   ...
    ├── src
    │    ├── components
    │    │      ├── Table.js
    │    │      ├── TableBody.js
    │    │      └── TableHead.js      
    │    ├── images
    │    ├── App.js
    │    ├── index.css
    │    └── index.js

Notice we also added an images folder in the src. This will hold the icons indicating the direction of sorting. Let’s get the icons from the project here and add them to the src/images folder.

Next, open the src/index.js and update the file so we have:

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
 <React.StrictMode>
  <App />
 </React.StrictMode>
);

And then, update the src/App.js file so we have the following:

const App = () => {
 return <>App</>;
};

export default App;

Before we save the files, let’s get the CSS for the project here to replace the src/index.css file. If we now save the files, we should see a simple “App” text rendered in the frontend.

Getting the table’s data

Usually when we work with tables, we get the table’s data from an API or a backend server asynchronously. However, for this tutorial, we will generate some mock but realistic data from Mockaroo and get the returned JSON data that looks like this:


More great articles from LogRocket:


[
 {
  "id": 1,
  "full_name": "Wendall Gripton",
  "email": "[email protected]",
  "gender": "Male",
  "age": 100,
  "start_date": "2022-01-26"
 },
 // ...
]

So, let’s create a data.json in the src folder, copy the data from the project file here, and paste it into the file we just created. Now, save the file.

In the file, you’ll notice we added some null values to represent the missing values. This was intentional to show how to properly sort values of the null data type.

Rendering the table data

In the components/Table.js file, let’s start by adding the following code:

import { useState } from "react";
import tableData1 from "../tableData1.json";
import TableBody from "./TableBody";
import TableHead from "./TableHead";

const Table = () => {
 const [tableData, setTableData] = useState(tableData1);

 const columns = [
  { label: "Full Name", accessor: "full_name" },
  { label: "Email", accessor: "email" },
  { label: "Gender", accessor: "gender" },
  { label: "Age", accessor: "age" },
  { label: "Start date", accessor: "start_date" },
 ];

 return (
  <>
   <table className="table">
    <caption>
     Developers currently enrolled in this course, column headers are sortable.
    </caption>
    <TableHead columns={columns} />
    <TableBody columns={columns} tableData={tableData} />
   </table>
  </>
 );
};

export default Table;

The code is self-explanatory. We imported the table data and stored it in the state. Then, we passed it to the TableBody component via the prop. We also defined the table headers as an array of objects and assigned them to the columns variable.

We can now loop through the variable in the TableHead component to display the table headers and also use the accessor keys to dynamically access and display the body row data. For this, the accessor must match the data keys in the src/tableData1.json file.

Next, let’s access the data in the children’s components so we can render them. Let’s add the following code in the components/TableHead.js file:

const TableHead = ({ columns }) => {
 return (
  <thead>
   <tr>
    {columns.map(({ label, accessor }) => {
     return <th key={accessor}>{label}</th>;
    })}
   </tr>
  </thead>
 );
};

export default TableHead;

Next, let’s add the following in the components/TableBody.js file:

const TableBody = ({ tableData, columns }) => {
 return (
  <tbody>
   {tableData.map((data) => {
    return (
     <tr key={data.id}>
      {columns.map(({ accessor }) => {
       const tData = data[accessor] ? data[accessor] : "——";
       return <td key={accessor}>{tData}</td>;
      })}
     </tr>
    );
   })}
  </tbody>
 );
};

export default TableBody;

Finally, update the src/App.js file to include the Table component:

import Table from "./components/Table";

const App = () => {
 return (
  <div className="table_container">
   <h1>Sortable table with React</h1>
   <Table />
  </div>
 );
};

export default App;

Let’s save all files and check the frontend. We should see our table rendered.

Rendered Table In Frontend

Sorting the React table data

Now, whenever we click any of the table headers, we can sort that particular column in ascending or descending order. To achieve this, we must use an ordering function that knows how to collate and order items. In this case, we will use the sort() function.

However, depending on the item’s data type, we can sort elements using different methods. Let’s take a look at that real quick.

The basic sort() function

In the simplest form, we can use the sort() function to arrange the elements in the arr array:

const arr = [3, 9, 6, 1];

arr.sort((a, b) => a - b);
console.log(arr); // [1, 3, 6, 9]

The sort(), through its algorithm, knows how to compare its elements. By default, it sorts in ascending order. The above syntax works if the sort items are numbers. For strings, we have something like this:

const arr2 = ["z", "a", "b", "c"];

arr2.sort((a, b) => (a < b ? -1 : 1));
console.log(arr2); // ["a", "b", "c", "z"]

Here, the sort() compares items and returns an integer to know if an item is moved up or down in the list. In the above implementation, if the compare function returns a negative number, the first item, a, is less than b, and therefore moved up, which indicate ascending order and vice versa.

Understanding the sorting principle with the sort() function is vital to ordering table data. Now, let’s see more examples.

If we sort the following data by name:

const data = [
  { name: "Ibas", age: 100 },
  {
    name: "doe",
    age: 36
  }
];

We will have the following code:

const data1 = [...data].sort((a, b) => (a.name < b.name ? -1 : 1));

data1.map((d) => console.log("without conversion", d.name)); // Ibas, doe

Notice the output is not what we expect. We expect doe to be listed before Ibas based on the default ascending rules. Well, this happens because characters are sorted by their Unicode values.

In the Unicode table, capital letters have a lesser value than small letters. To ensure we see the expected result, we must sort by case-insensitive by converting the sort items to lower cases or upper cases.

Our code should now look like so:

const data2 = [...data].sort((a, b) =>
  a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1
);

data2.map((d) => console.log("with conversion", d.name)); // doe, Ibas

This works as expected. However, in our project, we will sort the table headers by different data types, which includes number, string and date.

In the above implementation, we cannot pass a number because the .toLowerCase() function only exists on strings. This is where the localeCompare() function comes in.

Using localeCompare() with the sort() function

This function is capable of handling different data types including strings in different languages so they appear in the right order. This is perfect for our use case.

If we use it to sort our data array by name, we have the following:

const data3 = [...data].sort((a, b) => a.name.localeCompare(b.name));

data3.map((d) => console.log("with localeCompare", d.name, d.age)); // doe 36, Ibas 100

Like the earlier compare function, the localeCompare() also returns a number. In the above implementation, it returns a negative number if the a.name is less than b.name and vice versa. This function is only applied on a string, but it provides an option for numeric sorting.

Back to our data array, we can sort by age number by calling .toString() on age to get a string representation:

const data4 = [...data].sort((a, b) =>
  a.age.toString().localeCompare(b.age.toString())
);

data4.map((d) => console.log("with localeCompare", d.name, d.age)); // ibas 100, doe 36

Again, in the code we notice that 100 is coming before 36 which is not what we expect. This also happens because the values are strings and hence, "100" < "36" is correct. For numeric sorting, we must specify the numeric option, so we have:

const data5 = [...data].sort((a, b) =>
  a.age.toString().localeCompare(b.age.toString(), "en", {
    numeric: true
  })
);

data5.map((d) => console.log("with localeCompare", d.name, d.age)); /// doe 36, Ibas 100

As seen above, we are now getting the proper arrangement. In the code, we also included an optional "en" locale to specify the application language.

Now that we’ve refreshed how to use the sort() function, implementing it in our project will be a piece of cake.

Handling the onClick event and sorting data

When we click a particular table header, we must keep track of the sort order and the sort column. For this, we must use the useState Hook.

In the components/TableHead.js, import the useState Hook and use it like so:

import { useState } from "react";

const TableHead = ({ columns }) => {
 const [sortField, setSortField] = useState("");
 const [order, setOrder] = useState("asc");
 return (
  // ...
 );
};

export default TableHead;

Next, add an onClick event to the table header, th, and its handler function above the return statement:

const TableHead = ({ columns }) => {
 // ...
 const handleSortingChange = (accessor) => {
  console.log(accessor);
 };
 return (
  <thead>
   <tr>
    {columns.map(({ label, accessor }) => {
     return (
      <th key={accessor} onClick={() => handleSortingChange(accessor)}>
       {label}
      </th>
     );
    })}
   </tr>
  </thead>
 );
};

export default TableHead;

Let’s save the file. If we open the console and click the table headers, we should see their respective accessor keys.

Next, let’s define the logic to switch the order on every header click by updating the handleSortingChange handler so we have the following:

const handleSortingChange = (accessor) => {
 const sortOrder =
  accessor === sortField && order === "asc" ? "desc" : "asc";
 setSortField(accessor);
 setOrder(sortOrder);
 handleSorting(accessor, sortOrder);
};

At this point, we have access to the latest sort order. Now, to manipulate the table data, we must pass the order up a level to the parent Table component, and we’ve done that using the handleSorting() function call.

This is because the Table component is the one that holds the data in the state. And therefore, it is the only component that can change it. In React, we can raise an event like we did in this file and then handle it in the parent component via the props.

So, before we save the file, let’s ensure we destructure the component prop and have access to the handleSorting like so:

const TableHead = ({ columns, handleSorting }) => {

Now, save the file.

Next, let’s open the components/Table.js file to handle the event. First, in the return, let’s ensure we pass the handleSorting to the TableHead instance as a prop:

return (
 <>
  <table className="table">
   {/* ... */}
   <TableHead columns={columns} handleSorting={handleSorting} />
   <TableBody columns={columns} tableData={tableData} />
  </table>
 </>
);

We can rewrite the above to look simpler, like so:

return (
 <>
  <table className="table">
   {/* ... */}
   <TableHead {...{ columns, handleSorting }} />
   <TableBody {...{ columns, tableData }} />
  </table>
 </>
);

Either of the above methods is fine.

Finally, let’s add the handleSorting handler above the return statement:

const handleSorting = (sortField, sortOrder) => {
 console.log(sortField, sortOrder)
};

Let’s save all files.

The handleSorting handler expects two parameters because we passed them from the TableHead component. In the meantime, we log these parameters to the console whenever we click the table headers.

Next, we will use the sort() function alongside the localeCompare() to properly sort the table data. Fortunately, we learned that earlier in this tutorial.

By applying the sorting logic, the handleSorting handler now looks like this:

const handleSorting = (sortField, sortOrder) => {
 if (sortField) {
  const sorted = [...tableData].sort((a, b) => {
   return (
    a[sortField].toString().localeCompare(b[sortField].toString(), "en", {
     numeric: true,
    }) * (sortOrder === "asc" ? 1 : -1)
   );
  });
  setTableData(sorted);
 }
};

The code should be clear enough. If you need a refresher, please revisit the earlier explanation. Here, we sort the table data by the column headers and then update the tableData state via the setTableData() updater function.

Notice how we reverse the sort order by checking for the "asc" value and switch the returned value.

Let’s save and test our projects.

The project should work until we click on the column that includes null values. Let’s handle that by updating the handler to check for null values:

const handleSorting = (sortField, sortOrder) => {
 if (sortField) {
  const sorted = [...tableData].sort((a, b) => {
   if (a[sortField] === null) return 1;
   if (b[sortField] === null) return -1;
   if (a[sortField] === null && b[sortField] === null) return 0;
   return (
    a[sortField].toString().localeCompare(b[sortField].toString(), "en", {
     numeric: true,
    }) * (sortOrder === "asc" ? 1 : -1)
   );
  });
  setTableData(sorted);
 }
};

Now, save the file and test the project. It should work.

Enabling or disabling sorting for specific columns

Next, let’s add a sortable key to the columns items and specify a Boolean value to allow or disallow sorting for any column.

const columns = [
 { label: "Full Name", accessor: "full_name", sortable: true },
 { label: "Email", accessor: "email", sortable: false },
 { label: "Gender", accessor: "gender", sortable: true },
 { label: "Age", accessor: "age", sortable: true },
 { label: "Start date", accessor: "start_date", sortable: true },
];

Then, we can access this key in the TableHead component and use it to activate a click event like so:

return (
 <thead>
  <tr>
   {columns.map(({ label, accessor, sortable }) => {
    return (
     <th
      key={accessor}
      onClick={sortable ? () => handleSortingChange(accessor) : null}
     >
      {label}
     </th>
    );
   })}
  </tr>
 </thead>
);

Now, we should be able to sort any columns except those with a sortable value of false.

Displaying icons to indicate the sorting direction

This is straightforward. Here, let’s dynamically add the default, up, and down class names to the table header, th, element. These classes are already styled in our CSS file to add arrow icons. In the components/TableHead.js file, update the return statement so we have the following:

return (
 <thead>
  <tr>
   {columns.map(({ label, accessor, sortable }) => {
    const cl = sortable
     ? sortField === accessor && order === "asc"
      ? "up"
      : sortField === accessor && order === "desc"
      ? "down"
      : "default"
     : "";
    return (
     <th
      // ...
      className={cl}
     >
      {label}
     </th>
    );
   })}
  </tr>
 </thead>
);

In the code, we use the nested ternary operator to check the order status and assign class names accordingly. Save and test your project.

Making the table reusable

React allows us to reuse component logic if the need arises. For instance, if we need to include another data table in a different section of our project, we can extract and share similar logic between components using a custom Hook.

Here, we’ll create a custom Hook to reuse sorting functionality in different table components.

Extracting the sorting logic

Let’s start by extracting the sorting logic into a reusable function called useSortableTable. In the src folder, create a file called useSortableTable.js and add the following code:

import { useState } from "react";
export const useSortableTable = (data) => {
 const [tableData, setTableData] = useState(data);

 const handleSorting = (sortField, sortOrder) => {
  if (sortField) {
   const sorted = [...tableData].sort((a, b) => {
    if (a[sortField] === null) return 1;
    if (b[sortField] === null) return -1;
    if (a[sortField] === null && b[sortField] === null) return 0;
    return (
     a[sortField].toString().localeCompare(b[sortField].toString(), "en", {
      numeric: true,
     }) * (sortOrder === "asc" ? 1 : -1)
    );
   });
   setTableData(sorted);
  }
 };

 return [tableData, handleSorting];
};

In the above code, we’ve only extracted the sorting logic into a custom Hook file. The Hook takes the table data as an argument and returns the sorted data when we click the column header. Next, we’ll use the Hook in place of the logic in the Table component.

Open the components/Table.js file. Then, import and use the useSortableTable() custom Hook like so:

// ...
import { useSortableTable } from "../useSortableTable";

const Table = () => {
 const columns = [
  // ...
 ];
 const [tableData, handleSorting] = useSortableTable(tableData1);
 return (
  // ...
 );
};

export default Table;

With this implementation, the Table component contains lesser code. If we save the files and test our project, it should work as expected. Now we can reuse the logic in any other table component.

Creating a reusable Table component

In the previous section, we learned how to reuse component logic with a custom Hook. This enables us to reuse the sorting functionality. Now let’s see how we can further abstract the table logic and only expose a Table instance where we can pass along some props related to a specific table.

In our code we are currently rendering the <Table /> in the src/App.js. Let’s bring all the data related to that table from the Table component and use it inside the src/App.js. This data includes the table body content, columns, and table caption.

The src/App.js file now looks like so:

import Table from "./components/Table";
import tableData1 from "./tableData1.json";

const columns = [
 // ...
];

const App = () => {
 return (
  <div className="table_container">
   <h1>Sortable table with React</h1>
   <Table
    caption="Developers currently enrolled in this course, column headers are sortable."
    data={tableData1}
    columns={columns}
   />
  </div>
 );
};

export default App;

Now, we can receive the props from the Table component and then use them in our render:

// ...
const Table = ({ caption, data, columns }) => {
 const [tableData, handleSorting] = useSortableTable(data);

 return (
  <>
   <table className="table">
    <caption>{caption}</caption>
    <TableHead {...{ columns, handleSorting }} />
    <TableBody {...{ columns, tableData }} />
   </table>
  </>
 );
};

export default Table;

Now, save the files and test the project.

Rendering another data table

Let’s get another data table and render it using the same Table component. To start, create a tableData2.json file in the src folder. Then, copy the data from the project file and paste it into the tableData2.json file.

Next, let’s create a component file called RenderAnotherTable.js in the components folder and render the new table.

import Table from "./Table";
import tableData2 from "../tableData2.json";

const columns = [
 { label: "Name", accessor: "name", sortable: true },
 { label: "Country", accessor: "country", sortable: true },
 { label: "GitHub username", accessor: "github_username", sortable: true },
 { label: "Course price", accessor: "money", sortable: true },
];

const RenderAnotherTable = () => {
 return (
  <>
   <Table
    caption="List of developers with an affordable course."
    data={tableData2}
    columns={columns}
   />
  </>
 );
};

export default RenderAnotherTable;

Now, we can render this new component inside any file. In our case, we’ll render it in the src/App.js file:

import RenderAnotherTable from "./components/RenderAnotherTable";
// ...
const App = () => {
 return (
  <div className="table_container">
   {/* ... */}
   <br />
   <RenderAnotherTable />
  </div>
 );
};

export default App;

Save all files and test your project. It should work as expected.

Default sorting by column

Let’s add logic to enable us to order the table by a specific column on the initial render. Let’s find the columns array in the src/App.js file and add a sortbyOrder key with a desc value:

const columns = [
 // ...
 { label: "Gender", accessor: "gender", sortable: true, sortbyOrder: "desc" },
 // ...
];

With this, we will have the table sorted in descending order according to the G``ender column.

In the Table component, let’s also pass the columns to the useSortableTable Hook so that we can define the default sort functionality.

const Table = ({ caption, data, columns }) => {
 const [tableData, handleSorting] = useSortableTable(data, columns);

 return (
  // ...
 );
};

export default Table;

Next, open the src/useSortableTable.js file and access the columns:

export const useSortableTable = (data, columns) => {
 const [tableData, setTableData] = useState(data);

 const handleSorting = (sortField, sortOrder) => {
  // ...
 };

 return [tableData, handleSorting];
};

On the initial render, instead of passing the unsorted table data to the state, we’ll pass a function that detects if we want to default sort on a particular column, and then returns the sorted table.

So let’s initialize the state with a function that takes the data and column and defines the default sort logic like so:

function getDefaultSorting(defaultTableData, columns) {
 const sorted = [...defaultTableData].sort((a, b) => {
  const filterColumn = columns.filter((column) => column.sortbyOrder);

  // Merge all array objects into single object and extract accessor and sortbyOrder keys
  let { accessor = "id", sortbyOrder = "asc" } = Object.assign(
   {},
   ...filterColumn
  );

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

  const ascending = a[accessor]
   .toString()
   .localeCompare(b[accessor].toString(), "en", {
    numeric: true,
   });

  return sortbyOrder === "asc" ? ascending : -ascending;
 });
 return sorted;
}

export const useSortableTable = (data, columns) => {
 const [tableData, setTableData] = useState(getDefaultSorting(data, columns));

 // ...
 return [tableData, handleSorting];
};

On the initial render, we check if any of the columns have the sortbyOrder key and then sort the column by the specified value; either ascending or descending. If we save our files and test the project, it should work. You can see for yourself here.

That’s pretty much it!

Conclusion

Adding sorting functionality to a table is vital for data management and improves user experience, especially for a table of many rows.

In this tutorial, we learned how to add the sort feature (including default sorting functionality by a specific column) to a React table without using any library. We also learned how to reuse the table logic to add another data table to a different section of our project.

If you liked this tutorial, endeavor to share it around the web. And, if you have questions or contributions, please share your thoughts in the comment section.

You can find the project source code on my GitHub.

Full visibility into production React apps

Debugging React applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your React apps — .

Ibadehin Mojeed I'm an advocate of project-based learning. I also write technical content around web development.

Leave a Reply