Learning React & Redux in 2023 — Part 15
The next component we’ll be working on is a table component consisting of date columns which can be sorted.
Firsat of all, we’ll create a Table component, a TablePage component, and then update our Sidebar and our App.js file to accomodate them.
Firstly Table.js
function Table() {
return <div> Table Component </div>
}
export default Table;
Next TablePage.js
import Table from '../components/Table';
function TablePage() {
return (
<div>
<Table />
</div>
);
}
export default TablePage;
Next we’ll update App.js
import Sidebar from "./components/Sidebar";
// import Link from "./components/Link";
import Route from "./components/Route";
import AccordionPage from "./pages/AccordionPage";
import ButtonPage from "./pages/ButtonPage";
import DropdownPage from "./pages/DropdownPage";
import ModalPage from "./pages/ModalPage";
import TablePage from "./pages/TablePage";
function App() {
return (
<div className='container mx-auto grid grid-cols-6 gap-8 mt-4'>
<div>
<Sidebar />
</div>
<div className='col-span-5 relative'>
<Route path="accordion">
<AccordionPage />
</Route>
<Route path="button">
<ButtonPage />
</Route>
<Route path="dropdown">
<DropdownPage />
</Route>
<Route path="modal">
<ModalPage />
</Route>
<Route path="table">
<TablePage />
</Route>
</div>
</div>
);
}
export default App;
and finally Sidebar.js
import Link from "./Link";
function Sidebar() {
const links = [
{ path: "accordion", label: "Accordion" },
{ path: "button", label: "Buttons" },
{ path: "dropdown", label: "Dropdown" },
{ path: "modal", label: "Modal" },
{ path: "table", label: "Table" },
];
const renderedLinks = links.map((link) => {
return ( <Link className='mb-3'
key={link.label}
to={link.path}
activeClassName='font-fold border-l-4 border-blue-700 pl-2'
>
{link.label}</Link> );
});
return (
<div className="sticky top-0 flex flex-col">
{renderedLinks}
</div>
);
}
export default Sidebar;
Now we’re ready to get started.
Here is the data we want to show to begin with:
Let’s begin with adding this data as an array of objects into TablePage.js and then create a prop for it.
import Table from '../components/Table';
function TablePage() {
const data = [
{ name: 'Orange', color: 'bg-orange-500', score: 5 },
{ name: 'Apple', color: 'bg-red-500', score: 3 },
{ name: 'Banana', color: 'bg-yellow-500', score: 1 },
{ name: 'Lime', color: 'bg-green-500', score: 4 },
];
return (
<div>
<Table data={data}/>
</div>
);
}
export default TablePage;
So we’ve created our object and create a prop called data which points to this new array of objects we created.
Next we’ll actually create the table structure. We’ll switch to Table.js and setup the structure, which is basic HTML table structure…
function Table() {
return <div>
<table>
<thead>
<tr>
<th>Fruits</th>
<th>Color</th>
<th>Score</th>
</tr>
</thead>
<tbody>
<tr>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td></td>
<td></td>
<td></td>
</tr>
</tbody>
</table>
</div>
}
export default Table;
We’ve added the headers. Now lets map the object array to the body of the table…
function Table( {data} ) {
const renderedRows = data.map((row) => {
return (
<tr key={row.name}>
<td>{row.name}</td>
<td>{row.color}</td>
<td>{row.score}</td>
</tr>
);
});
return <div>
<table>
<thead>
<tr>
<th>Fruits</th>
<th>Color</th>
<th>Score</th>
</tr>
</thead>
<tbody>
{renderedRows}
</tbody>
</table>
</div>
}
export default Table;
so we’ve utilized our data prop from TablePage.js and created a mapping function called renderedRows, which takes each object from the data prop, and creates some a <tr> (table row) with three <td> inside — one for name, one for color, and one for score.
The parent element also needs a unique key, so we assigned it the name.
Now we remove all the original <tr> elements and replace with the renderedRows data.
This give us the following:
before we go any further let’s add some basic tailwind css styling to make it look a bit neater…
function Table( {data} ) {
const renderedRows = data.map((row) => {
return (
<tr className="border-b" key={row.name}>
<td className="p-3">{row.name}</td>
<td className="p-3">{row.color}</td>
<td className="p-3">{row.score}</td>
</tr>
);
});
return <div>
<table className="table-auto border-spacing-2">
<thead>
<tr className="border-b-2">
<th>Fruits</th>
<th>Color</th>
<th>Score</th>
</tr>
</thead>
<tbody>
{renderedRows}
</tbody>
</table>
</div>
}
export default Table;
this makes the table now look like this:
Next we’ll convert the color row to show actual colors rather than the text…
Let’s amend our mapping function as follows:
const renderedRows = data.map((row) => {
return (
<tr className="border-b" key={row.name}>
<td className="p-3">{row.name}</td>
<td className="p-3"><div className={`p-3 m-2 ${row.color}`}></div></td>
<td className="p-3">{row.score}</td>
So now we’re placing a div where the row.color was
We’re giving the div some styling with className and then passing in the string of row.color as part of the className styling.
This makes the table look like this:
The table looks ok, but it’s certainly not in any way reusable at this stage. React components should ideally be re-usable, but we’ve got a lot of hard-coded information right now, so we need to begin to re-work the table component.
Let’s begin by removing the hard-coded headers from Table.js and get them into TablePage.js along with the table data.
We’ll make the following changes to TablePage.js first
import Table from '../components/Table';
function TablePage() {
const data = [
{ name: 'Orange', color: 'bg-orange-500', score: 5 },
{ name: 'Apple', color: 'bg-red-500', score: 3 },
{ name: 'Banana', color: 'bg-yellow-500', score: 1 },
{ name: 'Lime', color: 'bg-green-500', score: 4 },
];
const config = [
{ label: 'Name', key: 'name' },
{ label: 'Color', key: 'color' },
{ label: 'Score', key: 'score' },
];
return (
<div>
<Table data={data} config={config}/>
</div>
);
}
export default TablePage;
so we’ve created a new array object called “config” — We’ve also created a new prop (also called config) which references our array object.
Next we’ll re-factor Table.js
function Table( {data, config} ) {
const renderedHeaders = config.map((column) => {
return (
<th className="p-3" key={column.label}>{column.label}</th>
);
});
const renderedRows = data.map((row) => {
return (
<tr className="border-b" key={row.name}>
<td className="p-3">{row.name}</td>
<td className="p-3"><div className={`p-3 m-2 ${row.color}`}></div></td>
<td className="p-3">{row.score}</td>
</tr>
);
});
return <div>
<table className="table-auto border-spacing-2">
<thead>
<tr className="border-b-2">
{renderedHeaders}
</tr>
</thead>
<tbody>
{renderedRows}
</tbody>
</table>
</div>
}
export default Table;
so we’ve brought in our config prop. We’ve also created a const called renderedHeaders, which is used to map each object instance of config, and return a <th> element with a unique key, and also the column label.
We then place this {renderedHeaders} into the table structure, replacing the previous hard-coded <th> elements.
The next thing we’ll do is focus on rendering the individual cells in the table.
Let’s once again re-work TablePage.js
import Table from '../components/Table';
function TablePage() {
const data = [
{ name: 'Orange', color: 'bg-orange-500', score: 5 },
{ name: 'Apple', color: 'bg-red-500', score: 3 },
{ name: 'Banana', color: 'bg-yellow-500', score: 1 },
{ name: 'Lime', color: 'bg-green-500', score: 4 },
];
const config = [
{
label: 'Name',
render: (row)=> row.name
},
{
label: 'Color',
render: (row)=> row.color
},
{
label: 'Score',
render: (row)=> row.score
},
];
return (
<div>
<Table data={data} config={config}/>
</div>
);
}
export default TablePage;
so now our config array of objects includes two key/values per object. It has a label and a render key. The values for label are the headers, and the values for render are actual functions. The render function takes the object passed in as an argument and returns either the name, the color or the score.
Now let’s switch to Table.js component.
function Table( {data, config} ) {
const renderedHeaders = config.map((column) => {
return (
<th className="p-3" key={column.label}>{column.label}</th>
);
});
const renderedRows = data.map((row) => {
const renderedCells = config.map((column) => {
return <td className="p-3" key={column.label}>{column.render(row)}</td>;
});
return (
<tr className="border-b" key={row.name}>
{renderedCells}
</tr>
);
});
return <div>
<table className="table-auto border-spacing-2">
<thead>
<tr className="border-b-2">
{renderedHeaders}
</tr>
</thead>
<tbody>
{renderedRows}
</tbody>
</table>
</div>
}
export default Table;
We’ve added a new const called renderedCells, and this maps over the config instances, and for each instance creates a <td> element and inside each element, it returns the value produced from the render function with the value of row passed in.
Remember the value for row, is the mapped instance of data, so it will create one <td> element for each instance of data.
const data = [
{ name: 'Orange', color: 'bg-orange-500', score: 5 },
{ name: 'Apple', color: 'bg-red-500', score: 3 },
{ name: 'Banana', color: 'bg-yellow-500', score: 1 },
{ name: 'Lime', color: 'bg-green-500', score: 4 },
];
So basically, it creates an instance for each object in data, and then from each instance of data, it then maps an instance for each key/value pair in the instance.
The first instance is [0] which would be name: ‘Orange’ so it passes in to the render function and receives row.name (Orange)
The next instance is [1] which would be color: ‘bg-orange-500’ so it passes in to the render function and receives row.color (bg-orange-500)
The next instance is [2] which would be score: 5 so it passes in to the render function and receives row.score (5)
So you’re left with:
<td className="p-3" key={column.label}>Orange</td>
<td className="p-3" key={column.label}>bg-orange-500</td>
<td className="p-3" key={column.label}>5</td>
So when you’re actually rendering the {renderedRows} mapping, what you’re actually doing is rendering three renderedCells <td> elements for each <tr> element row.
So renderedRows now becomes:
<tr className="border-b" key={row.name}>
<td className="p-3" key={column.label}>Orange</td>
<td className="p-3" key={column.label}>bg-orange-500</td>
<td className="p-3" key={column.label}>5</td>
</tr>
<tr className="border-b" key={row.name}>
<td className="p-3" key={column.label}>Apple</td>
<td className="p-3" key={column.label}>bg-red-500</td>
<td className="p-3" key={column.label}>3</td>
</tr>
<tr className="border-b" key={row.name}>
<td className="p-3" key={column.label}>Banana</td>
<td className="p-3" key={column.label}>bg-yellow-500</td>
<td className="p-3" key={column.label}>1</td>
</tr>
<tr className="border-b" key={row.name}>
<td className="p-3" key={column.label}>Lime</td>
<td className="p-3" key={column.label}>bg-green-500</td>
<td className="p-3" key={column.label}>4</td>
</tr>
With this change the table looks like this…
so we need to create the colour boxes again, which is just a case of changing the render function for color…
const config = [
{
label: 'Name',
render: (row)=> row.name
},
{
label: 'Color',
render: (row)=> <div className={`p-3 m-2 ${row.color}`}></div>
},
{
label: 'Score',
render: (row)=> row.score
},
];
so now we’re back to rending a div with a class name that includes the tailwind css color value.
Customizing Header cells
Assuming our table ultimately ends up as a resuable component, we might want to put something else into our headers, rather than just a plain <th> element. Let’s rework the header cells so that we could potentially put anything we want into the headers.
Currently our Table.js component “renderedHeaders” includes the following:
const renderedHeaders = config.map((column) => {
return (
<th className="p-3" key={column.label}>{column.label}</th>
);
});
We’re mapping over “config” from TablePage, and then creating a <th> element instance for each object.
We can use the fact that we’re mapping over the config object to our advantage, but introducing an if statement there to state that if we have a header key/value in the config object array, then we should return that, instead of the <th> element.
So we can add in this:
const renderedHeaders = config.map((column) => {
if (column.header)
return column.header;
else
return (
<th className="p-3" key={column.label}>{column.label}</th>
);
});
Basically if “header” exists in the config object array, then return that instead of the <th>
We can test this by adding a key/value pair to the config object array in TablePage.js
const config = [
{
label: 'Name',
render: (row)=> row.name
},
{
label: 'Color',
render: (row)=> <div className={`p-3 m-2 ${row.color}`}></div>
},
{
label: 'Score',
render: (row)=> row.score,
header: <th className="bg-red-200">Score</th>
},
];
So we now have a header key/value pair in the config object array, and the fact that this exists means it will be rendered to the screen.
This does however produce a warning message in the console…
When we render this…
header: <th className="bg-red-200">Score</th>
React expects this <th> to have a key.
Now whilst you could potentially put in a key into this <th> directly, this isn’t the best way to fix it. This is what you COULD do to make the error go away, but isn’t recommended:
label: 'Score',
render: (row)=> row.score,
header: <th key= "1" className="bg-red-200">Score</th>
A better solution so this would be to use React Fragments.
React Fragments
React Fragments are “simple” components that can be used when you want to assign key props.
We’ll remove the key here so that it doesn’t need to be manually assigned…
label: 'Score',
render: (row)=> row.score,
header: <th className="bg-red-200">Score</th>
Next we’ll import Fragment from react, in Table.js
import { Fragment } from "react";
We can now use this as a component which will allow us to assign a key to it…
import { Fragment } from "react";
function Table( {data, config} ) {
const renderedHeaders = config.map((column) => {
if (column.header)
return <Fragment key={column.label}>{column.header}</Fragment>
else
return (
<th className="p-3" key={column.label}>{column.label}</th>
);
});
so now, if column.header exists, it will render the Fragment component, with a key of “label” and the actual JSX of the value of the header object key. It means we don’t have to define any manual keys, and the error goes away.
We could also even now get column.header to be a function, rather than the plain <th> element we’ve specified in TablePage. We can do this by adding in the () to the end of column.header…
if (column.header)
return <Fragment key={column.label}>{column.header()}</Fragment>
and then making the value of header a function:
label: 'Score',
render: (row)=> row.score,
header: ()=> <th className="bg-red-200">Score</th>
},
Making a key function
We cannot assume that our data.name will always be something unique. Currently it is, as it’s set to names of Fruits, but it might not always be unique for every row, especially if it was a table consisting of peoples names, or other duplicate data.
So for example, in our data object array, we have this:
const data = [
{ name: 'Orange', color: 'bg-orange-500', score: 5 },
{ name: 'Apple', color: 'bg-red-500', score: 3 },
{ name: 'Banana', color: 'bg-yellow-500', score: 1 },
{ name: 'Lime', color: 'bg-green-500', score: 4 },
];
and we use the “row.name” as the unique key for our TablePage file
<tr className="border-b" key={row.name}>
{renderedCells}
</tr>
Rather than rely on our data object array having unique names for every row, let’s instead use a key function. We’ll create a new function called keyFunction and a new prop called keyFn which we can pass to Table.js
const keyFunction = (row) => {
return row.name;
};
return (
<div>
<Table data={data} config={config} keyFn={keyFunction}/>
</div>
);
}
export default TablePage;
Now if we switch to Table.js we’ll pass in this prop, and replace the previous key….
return (
<tr className="border-b" key={keyFn(row)}>
{renderedCells}
</tr>
);
So we’re using the prop and passing in the “row” object, so that we ultimately retireve row.name.
Technically w’ere getting the same information, but the benefit is that now we can define the key we want to use in TablePage rather than in Table component.
So we’ve specified in our keyFunction that we want to use row.name as the key, but we could have just as easily swapped this for something else, and critically it’s inside TablePage now.
Note: in other areas of Table.js we’ve used “column.label” as a key. Whilst you could create some kind of key function for the headers, it’s highly unlikely you’d have multiple table headers being the same. You would almost certainly have unique table headers, so here it’s not really an issue.
Sorting a table
We’re now going to implement table sorting, but the best approach to this is to introduce an intermediary component that will handle the sorting functionality, that sits between TablePage.js and Table.js — The benefit to this approach is that the data sorting element stays optional, so that you can reuse the Table.js component either with sorting functionality or without it. After all, you might not want some tables to be sortable.
We’ll create a new SortableTable.js component:
import Table from '../components/Table';
function SortableTable (props) {
return (
<Table {...props} />
)
}
export default SortableTable
So we’re importing Table.js so that SortableTable becomes the PARENT of Table.js
We’re also going to be bringing in ALL “props” from TablePage.js, so we pass in props, and then when we return the Table component, we pass all those props down to Table.
Next let’s update the TablePage component, so that it is now the parent of SortableTable.
So this…
import Table from '../components/Table';
…becomes this….
import Table from '../components/Table';
import SortableTable from '../components/SortableTable';
Note that we’ve still left in the import for Table….this is so that we could still use our Table component without sorting functionality if we want.
As we’re planning on making a sortable table at the moment, we’ll also return SortableTable instead of Table in our final return statement.
return (
<div>
<SortableTable data={data} config={config} keyFn={keyFunction}/>
</div>
);
Next we’ll add some new key/values in our data variable, for name and score, as these will be the two columns we’ll focus on to provide sorting. For name it will be to sort alphabetically, and for score it will be to sort from hi to low, and vice versa low to hi.
const config = [
{
label: 'Name',
render: (row)=> row.name,
sortValue: (row)=> row.name
},
{
label: 'Color',
render: (row)=> <div className={`p-3 m-2 ${row.color}`}></div>
},
{
label: 'Score',
render: (row)=> row.score,
// header: ()=> <th className="bg-red-200">Score</th>,
sortValue: (row)=> row.score
},
];
NOTE: we can also comment out the header key/value for score, as we’re going to get SortableTable to manage the headers.
Let’s go back to SortableTable to add some code:
import Table from '../components/Table';
function SortableTable (props) {
const updatedConfig = props.config.map((column) => {
if (!column.sortValue) {
return column;
}
return {
...column,
header: () => <th>{column.label} is sortable </th>
};
});
return (
<Table {...props} config={updatedConfig} />
)
}
export default SortableTable
We’re already passing in all props as these will be forwarded to Table.js, but our SortableTable component still requires access to the config array object, so that we can map over it to see if any instances contain a new “sortValue” key/value pair.
As we map over each instance, we’re saying that if there isn’t an instance with sortValue (which in this case is the color column) then just return the object as is.
However, if it does contain sortValue, then return the following:
return {
...column,
header: () => <th>{column.label} is sortable </th>
};
So we’re returning a new object, that will have all the keys and values of the column object array, but it will also have a header function, which in itself will provide a <th> element with the value for label, as well as the text “is sortable at the end.
We’ll remove this extra text in a moment, but it will help test the functionality.
Finally, we returned out Table component, and we’re now not only passing down all props that we brought in from TablePage, but we’re now also passing down a config prop that points to the SortableTable’s updateConfig return statement we created.
return (
<Table {...props} config={updatedConfig} />
)
It’s important to note that …props will ALREADY be passing down a config prop, and now we’re passing down an extra (explicitly provided) prop called config. When you pass this down in this way, the explicitly provided prop will OVERWRITE the config prop that was already part of …props.
If we look in the browser, we now see the following:
Next we’ll add in a click event handler for the column headers so that when a user clicks on the column header for sortable columns, it will do something.
Let’s firstly just add in the event handler, and the onClick event:
import Table from '../components/Table';
function SortableTable (props) {
const handleClick = (column) => {
console.log(column, "clicked");
};
const updatedConfig = props.config.map((column) => {
if (!column.sortValue) {
return column;
}
return {
...column,
header: () => <th onClick={()=> handleClick(column.label)}>{column.label} is sortable </th>
};
});
return (
<Table {...props} config={updatedConfig} />
)
}
export default SortableTable
So we’ve created a handleClick event handler that current takes in an argument, then console logs that argument along with the word “clicked” at the end of it.
We then attach an onClick to the <th> elements that include the instances that include sortValue (the ones that should be clickable!) and this calls an arrow function that will call handleClick and pass in the column.label as the argument.
So now when we click on the Score header, we see this in the console log..
Keeping track of sort order
We’ll want to keep track of current sort order using a piece of state…the cycle for sort order would be as follows:
We should in fact use TWO pieces of state to achieve this. The first piece of state will track if the order is unsorted (null), or if the order is in ascending or descending.
The second piece of state tracks whether the Name column should be sorted, the Score column should be sorted, or in fact nothing should be sorted.
Let’s create these pieces of state, and then update our handleClick event handler.
import Table from '../components/Table';
import { useState } from 'react';
function SortableTable (props) {
const [sortOrder, setSortOrder] = useState(null);
const [sortBy, setSortBy] = useState(null);
const handleClick = (column) => {
if (sortOrder === null) {
setSortOrder('asc');
setSortBy(column);
} else if (sortOrder === 'asc') {
setSortOrder('desc');
setSortBy(column);
} else if (sortOrder === 'desc') {
setSortOrder(null);
setSortBy(null);
}
};
const updatedConfig = props.config.map((column) => {
if (!column.sortValue) {
return column;
}
return {
...column,
header: () => <th onClick={()=> handleClick(column.label)}>{column.label} is sortable </th>
};
});
return (
<Table {...props} config={updatedConfig} />
)
}
export default SortableTable
so we’ve imported useState from React, we’ve then created our two pieces of state, and set both values to be null as initial values.
We’ve then updated our handleClick so that if the current sortOrder is null, then we update our sortOrder state to be “asc” and we update our sortBy piece of state to be whatever the label is.
Similarly, if the current sortOrder is asc, then we update our sortOrder state to be “desc” and we again update our sortBy piece of state to be whatever the label is.
Finally, if the current sortOrder is desc, then we update our sortOrder state to be “null” and we update our sortBy piece of state to be null.
Next we’ll create a new let called sortedData, which will represent a 1:1 copy of the data object array that was passed in from props.
We’ll then create the following if statement to see if this copied object array should be manipulated in any way.
let sortedData = props.data;
if (sortOrder && sortBy) {
const { sortValue } = props.config.find((column) => column.label === sortBy);
sortedData = [...props.data].sort((a, b) => {
const aValue = sortValue(a);
const bValue = sortValue(b);
const reverseOrder = sortOrder === 'asc' ? 1 : -1;
if (typeof aValue === 'string') {
return aValue.localeCompare(bValue) * reverseOrder;
} else {
return (aValue - bValue) * reverseOrder;
}
});
}
return (
<div>
<Table {...props} data={sortedData} config={updatedConfig} />
</div>
);
}
export default SortableTable;
First of all, we’ll only proceed if both sortOrder AND sortBy pieces of state are not null. Assuming both pieces of state are not null, then we’ll use JavaScript find() to see if we can find any label in the config object array that matches the current “sortBy” piece of state. So for example, if the Name header was clicked, then the “sortBy” piece of state would be set to “Name”
const config = [
{
label: 'Name',
render: (row )=> row.name,
sortValue: (row) => row.name,
},
{
label: 'Color',
render: (row)=> <div className={`p-3 m-2 ${row.color}`}></div>
},
{
label: 'Score',
render: (row)=> row.score,
// header: ()=> <th className="bg-red-200">Score</th>,
sortValue: (row) => row.score,
},
];
so if we find an object in the config object array that includes ‘Name’ as a label, then we know it’s the object we want, which in this example, would be the first object in the config object array…
{
label: 'Name',
render: (row )=> row.name,
sortValue: (row) => row.name,
},
We’re then going to de-structure out the sortValue function from this object, which gives us:
sortValue: (row) => row.name,
So sortValue will give us the value of name (e.g. Orange)
With that in mind, we now focus on “sortedData” which uses the JavaScript sort() function to create a new array copy of sorted values.
So we pass into the sort function, all of the spread data objects…
const data = [
{ name: 'Orange', color: 'bg-orange-500', score: 5 },
{ name: 'Apple', color: 'bg-red-500', score: 3 },
{ name: 'Banana', color: 'bg-yellow-500', score: 1 },
{ name: 'Lime', color: 'bg-green-500', score: 4 },
];
So we define aValue, which is technically referencing the name value in the second object in the array (Apple, inside the object with an index of 1) and we define bValue which is referencing the name value in the first object in the array (Orange, inside the object with an index of 0)
This comparison is then used by the sort function to re-arrange the objects inside the array. Note that the objects themselves don’t change, they simply change their index value inside the array, when being sorted.
sortedData = [...props.data].sort((a, b) => {
const aValue = sortValue(a);
const bValue = sortValue(b);
// assign either 1 or -1 to reverseOrder
const reverseOrder = sortOrder === 'asc' ? 1 : -1;
// if the value is a string, use localeCompare to sort it
if (typeof aValue === 'string') {
return aValue.localeCompare(bValue) * reverseOrder;
// else sort it by number
} else {
return (aValue - bValue) * reverseOrder;
}
}
So for example, the object containing Orange, might have started with an index of 0, but might end up with an index of 2 after being sorted.
If aValue is a string, then we’d use aValue.localeCompare(bValue) * reverseOrder to sort the values
If aValue is a number then we’d use (aValue — bValue) * reverseOrder to sort the values
Once sort is completed, we have a copy of the data object, that will either have been sorted by name or by score, and will have been sorted in ascending or descending order, depending on the sortOrder piece of state.
we then provide this sortedData object as our data prop being passed to the Table component, and once again, because we are explicitly providing the data prop, it will override the data prop already provided in …props
return (
<div>
<Table {...props} data={sortedData} config={updatedConfig} />
</div>
);
Next we’ll look to add in the correct icons into the headers. Both the Name and Score headers should start with both up and down arrows, and when the user clicks on Name, it will switch to the up icon, then if they click again, it should switch to the down icon, and if they click again, it should go back to both icons. The same applies to the Score header.
Let’s update our code, firstly with just text to confirm we have the logic working correctly:
const updatedConfig = props.config.map((column) => {
if (!column.sortValue) {
return column;
}
return {
...column,
header: () => (
<th onClick={()=> handleClick(column.label)}>
{getIcons(column.label, sortBy, sortOrder)}{column.label}
</th>
),
};
});
Now our <th> element will have a function called getIcons (which we will write out in a moment) and the arguments passed into this function will be the column.label, the sortBy piece of state, and the sortOrder piece of state as well as the actual label {column.label} printed as well.
Next we have our function:
const getIcons = (label, sortBy, sortOrder) => {
if (label !== sortBy) {
return 'both';
}
if (sortOrder === 'asc') {
return '^';
} else if (sortOrder === 'desc') {
return 'v';
} else {
return null;
}
};
so we take in our three arguments. We then say that if column.label is NOT equal to sortBy, then we return a string of “both”.
This will apply when the label is “Name” and sortBy is null.
When a user then clicks on the “Name” header, this invokes the handleClick function…
const handleClick = (column) => {
if (sortOrder === null) {
setSortOrder('asc');
setSortBy(column);
} else if (sortOrder === 'asc') {
setSortOrder('desc');
setSortBy(column);
} else if (sortOrder === 'desc') {
setSortOrder(null);
setSortBy(null);
}
};
at this point sortBy will change (will no longer be null) and will be set to “Name”. It’s also important to note that at the same time, sortOrder will be updated to “asc”.
So back in our getIcons function, the label IS equal to the sortBy piece of state which means the first if statement is skipped, and will now go to the second if statement:
if (sortOrder === 'asc') {
return '^';
because sortOrder is now “asc”, it means it will now return “^” to indicate ascending order.
Again, if the user clicks once more, then the first two if statements will be skipped, and the third if statement will apply:
else if (sortOrder === 'desc') {
return 'v';
which will of course return the down arrow.
It’s important to not that every invocation of the handleClick event handler (when a user clicks) triggers a re-render because this handleClick event handler updates states, which causes a re-render.
If we now click on the Score header we’ll see the following in the browser:
We can now replace the text with the actual icons…
First we’ll import the BoxIcons set from React-icons so that we can utilize the up and down arrows in the set.
import { BiSolidChevronDown, BiSolidChevronUp } from "react-icons/bi";
Now we can update our getIcons function as follows:
const getIcons = (label, sortBy, sortOrder) => {
if (label !== sortBy) {
return <div><BiSolidChevronUp /><BiSolidChevronDown /></div>;
}
if (sortOrder === 'asc') {
return <div><BiSolidChevronUp /></div>;;
} else if (sortOrder === 'desc') {
return <div><BiSolidChevronDown /></div>;
} else {
return null;
}
};
The icons will now appear:
Whilst this is working, the styling doesn't look great, so we’ll wrap the content of the <th> element inside a div, and then apply a className to add some css styling to it:
const updatedConfig = props.config.map((column) => {
if (!column.sortValue) {
return column;
}
else return {
...column,
header: () => (
<th onClick={()=> handleClick(column.label)}>
<div className="flex flex-row items-center">
{getIcons(column.label, sortBy, sortOrder)}{column.label}
</div>
</th>
),
};
});
which now makes it look like this:
Fixing the sort order unexpected sorting behavior
At the moment, the way that sorting works is quite simple. It cycles through unsorted, ascending, descending and back to unsorted.
The issue is that if you happen to be in the position of the score column being ordered in ascending order, and then click on the Name header, you would then expect the Name column to show ascending order, but instead it will show the column in descending order, as this is the next option in the cycle.
We can fix this with an amendment to the handleClick code:
const handleClick = (column) => {
if (sortBy && column !== sortBy) {
setSortOrder('asc');
setSortBy(column);
return;
};
if (sortOrder === null) {
setSortOrder('asc');
setSortBy(column);
} else if (sortOrder === 'asc') {
setSortOrder('desc');
setSortBy(column);
} else if (sortOrder === 'desc') {
setSortOrder(null);
setSortBy(null);
}
};
so we’re adding in an extra if statement at the beginning that says if sortBy is not null (i.e. it’s “Name” or “Score”) then set the sortOrder piece of state to be ascending, and update the sortBy to be the new label of Name or Score.
So essentially, if one header is already being sorted, and then you click on the other sortable header, then start the new header at ascending.
Making a reusable sorting hook
The next thing we will focus on is creating a reusable sorting hook, that will extract a lot of the logic from SortableTable and place in a separate file called “useSort” — The benefit of doing this is that we’ll be able to use this new hook to sort other things, not just tables, but lists.
The first step is to create a new file in the hooks folder called useSort.js
import {useState, useEffect} from 'react';
function useSort() {
return <div>useSort</div>;
}
export default useSort;
Next we’ll find all non-JSX expressions and functions in SortableTable and cut/paste them into our new hook….
import {useState, useEffect} from 'react';
function useSort() {
const [sortOrder, setSortOrder] = useState(null);
const [sortBy, setSortBy] = useState(null);
const handleClick = (column) => {
if (sortBy && column !== sortBy) {
setSortOrder('asc');
setSortBy(column);
return;
};
if (sortOrder === null) {
setSortOrder('asc');
setSortBy(column);
} else if (sortOrder === 'asc') {
setSortOrder('desc');
setSortBy(column);
} else if (sortOrder === 'desc') {
setSortOrder(null);
setSortBy(null);
}
};
// array of objects from TablePage.js
let sortedData = props.data;
// two pieces of state from above
if (sortOrder && sortBy) {
// find the column that matches the sortBy piece of state and assign it to sortValue
// e.g. sortBy = 'name', which when used with sortValue will return the value
// e.g. Orange
const { sortValue } = props.config.find((column) => column.label === sortBy);
// create a shallow copy of the data array
// and sort it by the sortValue (either name or score)
sortedData = [...props.data].sort((a, b) => {
const aValue = sortValue(a); // e.g. Orange
const bValue = sortValue(b); // e.g. Apple
// assign either 1 or -1 to reverseOrder
const reverseOrder = sortOrder === 'asc' ? 1 : -1;
// if the value is a string, use localeCompare to sort it
if (typeof aValue === 'string') {
return aValue.localeCompare(bValue) * reverseOrder;
// else sort it by number
} else {
return (aValue - bValue) * reverseOrder;
}
}
);
}
}
export default useSort;
once we do this, we’ll see some errors in the browser if using ESLint…
We’ll need to account for these four things, so at the bottom of useSort, we’ll return these four things so that SortableTable can access them.
import {useState, useEffect} from 'react';
function useSort() {
const [sortOrder, setSortOrder] = useState(null);
const [sortBy, setSortBy] = useState(null);
const handleClick = (column) => {
if (sortBy && column !== sortBy) {
setSortOrder('asc');
setSortBy(column);
return;
};
if (sortOrder === null) {
setSortOrder('asc');
setSortBy(column);
} else if (sortOrder === 'asc') {
setSortOrder('desc');
setSortBy(column);
} else if (sortOrder === 'desc') {
setSortOrder(null);
setSortBy(null);
}
};
// array of objects from TablePage.js
let sortedData = props.data;
// two pieces of state from above
if (sortOrder && sortBy) {
// find the column that matches the sortBy piece of state and assign it to sortValue
// e.g. sortBy = 'name', which when used with sortValue will return the value
// e.g. Orange
const { sortValue } = props.config.find((column) => column.label === sortBy);
// create a shallow copy of the data array
// and sort it by the sortValue (either name or score)
sortedData = [...props.data].sort((a, b) => {
const aValue = sortValue(a); // e.g. Orange
const bValue = sortValue(b); // e.g. Apple
// assign either 1 or -1 to reverseOrder
const reverseOrder = sortOrder === 'asc' ? 1 : -1;
// if the value is a string, use localeCompare to sort it
if (typeof aValue === 'string') {
return aValue.localeCompare(bValue) * reverseOrder;
// else sort it by number
} else {
return (aValue - bValue) * reverseOrder;
}
}
);
}
return {handleClick, sortedData, sortOrder, sortBy};
}
export default useSort;
so we’ve now added
return {handleClick, sortedData, sortOrder, sortBy};
Next we need to import our new hook, and then destructure out those elements…
import useSort from '../hooks/useSort';
function SortableTable (props) {
const { sortBy, sortOrder, sortedData, handleClick } = useSort();
Now we’ll see some different errors in the browser….
So we know we need to define some props. Back in useSort, we’ll update the code to add in these arguments to the useEffect function:
import {useState, useEffect} from 'react';
function useSort(data, config) {
const [sortOrder, setSortOrder] = useState(null);
const [sortBy, setSortBy] = useState(null);
const handleClick = (column) => {
if (sortBy && column !== sortBy) {
setSortOrder('asc');
setSortBy(column);
return;
};
if (sortOrder === null) {
setSortOrder('asc');
setSortBy(column);
} else if (sortOrder === 'asc') {
setSortOrder('desc');
setSortBy(column);
} else if (sortOrder === 'desc') {
setSortOrder(null);
setSortBy(null);
}
};
// array of objects from TablePage.js
let sortedData = data;
// two pieces of state from above
if (sortOrder && sortBy) {
// find the column that matches the sortBy piece of state and assign it to sortValue
// e.g. sortBy = 'name', which when used with sortValue will return the value
// e.g. Orange
const { sortValue } = config.find((column) => column.label === sortBy);
// create a shallow copy of the data array
// and sort it by the sortValue (either name or score)
sortedData = [...data].sort((a, b) => {
const aValue = sortValue(a); // e.g. Orange
const bValue = sortValue(b); // e.g. Apple
// assign either 1 or -1 to reverseOrder
const reverseOrder = sortOrder === 'asc' ? 1 : -1;
// if the value is a string, use localeCompare to sort it
if (typeof aValue === 'string') {
return aValue.localeCompare(bValue) * reverseOrder;
// else sort it by number
} else {
return (aValue - bValue) * reverseOrder;
}
}
);
}
return {handleClick, sortedData, sortOrder, sortBy};
}
export default useSort;
so we’re now going to be receiving data and config into this function. We of course then to ensure we’re passing those props to useSort, so back in SortableTable, we add them as follows:
function SortableTable (props) {
const { sortBy, sortOrder, sortedData, handleClick } = useSort(props.data, props.config);
we now have a reusable hook