While building my custom chess tournament manager (Github link), I frequently found myself dealing with tabular data. A table would look something like this:
const players = [
{id: 0, name: "Joel", rating: 1216},
{id: 1, name: "Crow", rating: 1153},
... // and so on
];
I had tables of users, tables of tournaments, tables of matches, all kinds of tables. Each had their own specific properties. Once the tables reached a certain size, I needed a way to sort them. Thankfully, React makes this extremely simple.
A quick note
For my project, and in these examples, I use the Ramda utility library. If you’re not used to Ramda’s functional-programming style, some of this code may look odd at first. Bear with me, and it will all come together 😉.
Setting up the state
To manage the state of a sorted table, we need three variables: the sort-key, the direction of the sort, and the table itself. The key will simply be a string that represents an object’s property. The direction is binary, either ascending or descending, so it can be stored as a boolean. Our state will then look something like this:
{key: "name", isDescending: false, table: [...]}
You could easily go with isAscending
for the direction property instead, but that’s an implementation decision you’ll have to make. In the end, it doesn’t matter which you choose.
Writing a reducer
Now that we know how our state object will look, we need to write a reducer function to update it. Here’s the one I came up with:
import {ascend, descend, prop, sort} from "ramda";
function sortedTableReducer(oldState, newState) {
const {isDescending, key, table} = {...oldState, ...newState};
const direction = isDescending ? descend : ascend;
const sortFunc = sort(direction(prop(key)));
return {isDescending, key, table: sortFunc(table)};
}
There’s a lot happening in those four lines, so let’s break it down:
1. Update the state
const {isDescending, key, table} = {...oldState, ...newState};
This first line merges the old state with the new state, and then destructures the result into the three variables we need. The newState
argument could potentially be {key: "rating"}
, {isDescending: true}
, a whole new table
, or any combination of those. Any unspecified properties will remain unchanged from the original state.
2. Determine the sort direction
const direction = isDescending ? descend : ascend;
Ramda has two functions, ascend
and descend
, which can create new functions for sort comparisons. Here, we’re simply determining which function we want.
3. Create a sort function
const sortFunc = sort(direction(prop(key)));
Here’s where one of Ramda’s big features comes into play: partial application. sort
, direction
, and prop
are all functions, but we’re only partially applying them to generate new functions (also known as currying).
Let’s break it down:
prop
retrieves a specified property from a specified object. From Ramda’s documentation: prop('x', {x: 100}); //=> 100
. Because we only supplied one argument, prop
just returns a new function that behaves as prop
with the first argument already applied. prop(key)
is like a terser way to write (x) => prop(key, x)
, or prop.bind(null, key)
.
As we already know, direction
is either ascend
or descend
. By calling, for example, ascend(prop("rating"))
, we’re creating a new function that will compare two objects based on their rating
properties, and return a boolean to indicate which one should come before the other.
Finally, sort
is analogous to JavaScript’s built-in Array.prototype.sort
. It takes two arguments: a comparator function and an array. We’ve already created our comparator function, so that gets passed as the first argument.
By leaving the array argument blank, we’re taking advantage of Ramda’s currying again. sort
returns a new function that will sort any array based on the function we already supplied.
This may seem like a lot to take in, but that’s the beauty of Ramda (and similar libraries). You can pack a lot of logic into tiny lines of code.
4. Return the updated state
return {isDescending, key, table: sortFunc(table)};
At last, we can return the updated object with the table sorted according to our sorting function.
Using the reducer
Inside your component, you can use the reducer with, well, useReducer
:
const initialState = {key: "name", isDescending: false, table: players}
const [sortedPlayers, sortedDispatch] = useReducer(sortedTableReducer, initialState);
You can output the sorted table within JSX as:
{sortedPlayers.table.map((player) => ...)}
You can update the sort key:
sortedDispatch({key: "name"});
You can toggle the sort order:
sortedDispatch({isDescending: !sortedPlayers.isDescending});
And you can update the data:
const newPlayers = players.concat([{id: 3, name: "Tom", rating: 2500}]);
sortedDispatch({table: newPlayers});
I noticed one flaw, however. I had no guarantee that tabular data would be sorted initially (in fact, it typically wasn’t). There are a couple of ways you can remedy this. One method would be to extract the sort function from the reducer and call it on your array before passing it to useReducer
. One downside to that strategy is that the function will sort the initial data on every re-render. If the sorting is expensive, and if the component renders frequently, this can be a performance drain.
If your instinct is to memoize the initial data with useMemo
, there’s a simpler solution. React’s useEffect
hook elegantly solves this problem for us, and we don’t even need to extract the sort function:
useEffect(
function callDispatchOnceToTriggerInitialSort() {
sortedDispatch({});
},
[] // <-- This ensures the effect only fires once
);
The empty object passed to the dispatch will not change the state, so the dispatch will just sort the initial data.
Extracting the logic to a reusable hook
Since we want to use our code in multiple components, let’s extract it. Here’s the final result:
import {useEffect, useReducer} from "react";
import {ascend, descend, prop, sort} from "ramda";
function sortedTableReducer(oldState, newState) {
const {isDescending, key, table} = {...oldState, ...newState};
const direction = isDescending ? descend : ascend;
const sortFunc = sort(direction(prop(key)));
return {isDescending, key, table: sortFunc(table)};
}
function useSortedTable(table, key, isDescending = true) {
const initialState = {isDescending, key, table};
const [state, dispatch] = useReducer(sortedTableReducer, initialState);
useEffect(
function callDispatchOnceToTriggerInitialSort() {
dispatch({});
},
[]
);
return [state, dispatch];
}
This hook works on any type of data that JavaScript is able to natively compare with <
or >
: strings, numbers, and even dates. In your own project, you can provide more customization as necessary. For example, you could write your own sort function to compare complex objects, or use a function like Ramda’s sortWith
to sort using multiple keys at once.
Now that the hook is prepared, integrating it into components is easy. Here’s a simple example of how this would work:
Pretty nice, right? It works exactly* how you’d expect a sortable table to behave.
* (A user’s definition of “ascending” or “descending” for certain data types may differ from JavaScript’s interpretation. In my code, I left it as-is for simplicity.)
Extra credit: case insensitive sorting
I didn’t like how Javascript sorts uppercase and lowercase strings separately. To remedy this, I made a few adjustments.
First, I created a function that turns sorted strings to lowercase, while leaving non-strings as-is:
const toLowerCaseIfPossible = (x) => x.toLowerCase ? x.toLowerCase() : x;
Inside the reducer, I took advantage of Ramda’s pipe
function. pipe
creates a function which passes its argument to the first function provided, and then passes that function’s output as an argument to the next function.
const caseInsensitiveProp = pipe(prop(key), toLowerCaseIfPossible);
const sortFunc = sort(direction(caseInsensitiveProp));
Ta-da! Now strings are treated with case-insensitive sorting.
You can see the source code for this hook in my own app here.
Top comments (1)
First, I love the article! Well explained and kept simple for understanding.
I would just like to mention that it may be misleading to state that Ramba's
sort
option is likeArray.prototype.sort
. There is a difference that might be important to note, that Rambda'ssort
makes a COPY of the array, whereArray.prototype.sort
does not.