In order to do sorting and filtering declaratively (which is desirable), you should avoid storing results of sorting in state. Instead, put those things in state which can lead you to this result. This approach should be taken for all methods that are variations on existing data, given these methods are synchronous. Failing to follow this rule and putting the results of sorting in state breaks the rule of a single source of truth, and therefore the premise that all that is rendered, is a reflection of state.
rendered = fn(state);
There is an exceptions to this rule. Asynchronous methods may use state as a caching mechanism for the results, as re-calculating them would result in considerable costs, such as extra time, network calls, etc. Optimally, the window where such asynchronous results are not in sync with the dependent state should be taken into account. For example, you may inform the user if the results of a sort are out of sync with the dependent state by showing a loading indicator.
Let's say you are sorting a table; and the column that is currently used for sorting, has a little arrow that is pointing up or down.
rowData; // Contains all rows in the table along with their data per column
sortingColumn; // The colum to sort
sortingDirection; // The direction to sort this column (ascending or descending)
There are 3 variables here: rowData
, sortingColumn
and sortingDirection
.
If you are storing these variables in state, together with the sorted array, you
now have 2 sources of truth: the states sortingColumn
and sortingDirection
are also encoded in the indexes of the rowData
array. Now you have 2 choices
(actually only 1, but let's explore directions too): Either remove
sortingColumn
and sortingDirection
or stop encoding sorting into rowData
state.
sortingColumn
and sortingDirection
?If you drop the states for sortingColumn
and sortingDirection
, your
application will not be declarative anymore. Indeed, the column selection and
arrow would live somewhere else, outside of the React state. What is rendered
will not be a function of state anymore. This will make an application
unpredictable.
rowData
Encoding sorting into rowData
is problematic, because you may in the future
introduce new code that also changes the rowData
state. There is no guarantee
that this new code will take into account this existing sorting mechanism that
are already in place. Why would they? There is no indication that this array is
sorted, apart from the existing state. This new code would result in invalid
states. A proper state model means making invalid states impossible.
The prefered approach would then be to not store the sorting results in state, but to calculate them on the fly. Then you will not have to worry about keeping the rowData sorting in sync with the other state variables.
function App() {
const [sortingColumn, setSortingColumn] = useState(null);
const [sortingDirection, setSortingDirection] = useState('asc');
const [rowData, setRowData] = useState([]);
const sort = (column) => {
if (sortingColumn === column) {
setSortingDirection(sortingDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortingColumn(column);
setSortingDirection('asc');
}
};
// The sortedRowData is calculated on every render.
const sortedRowData = sortData(rowData, sortingColumn, sortingDirection);
rowData
The approach above will call sortData on every render, which will have some performance overhead.
Instead, you can use a memoization. Memoizing rowData will make sure that sorting is only done when the sorting column or direction changes (as well as on mount).
function App() {
const [sortingColumn, setSortingColumn] = useState(null);
const [sortingDirection, setSortingDirection] = useState('desc');
const [rowData, setRowData] = useState([]);
const sort = (column) => {
if (sortingColumn === column) {
setSortingDirection(sortingDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortingColumn(column);
setSortingDirection('asc');
}
};
const sortedRowData = useMemo(() => sortData(rowData, sortingColumn, sortingDirection), [rowData, sortingColumn, sortingDirection]);
By the way, the implementation of the sortData function is as follows
function sortData(rowData, sortingColumn, sortingDirection) {
if (sortingColumn === null) {
return rowData;
}
return rowData.slice().sort((a, b) => {
const aValue = a[sortingColumn];
const bValue = b[sortingColumn];
if (aValue === bValue) {
return 0;
}
if (sortingDirection === "asc") {
return aValue < bValue ? -1 : 1;
} else {
return aValue < bValue ? 1 : -1;
}
});
}
rowData
The prolem becomes even more apparent when you want to filter the data. Filtering is a more destructive operation than sorting, because it will remove rows from the data. A sub-optimal approach would again be to store the filtered data in state, and sync filtered state with other state variables such as sorting variables and the rowData itself.
The strategy you should take instead is equal to the one above, but instead of only sorting, you will also filter.
Let's say we want to filter the data based on user input. The logic could then look as follows:
function App() {
const [sortingColumn, setSortingColumn] = useState(null);
const [sortingDirection, setSortingDirection] = useState('asc');
const [userInput, setUserInput] = useState('');
const [rowData, setRowData] = useState([]);
const sort = (column) => {
if (sortingColumn === column) {
setSortingDirection(sortingDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortingColumn(column);
setSortingDirection('asc');
}
};
const sortedAndFilteredData = useMemo(() => {
const filteredData = filterData(rowData, userInput);
const sortedData = sortData(filteredData, sortingColumn, sortingDirection);
return sortedData;
}, [rowData, userInput, sortingColumn, sortingDirection]);
With filterData
looking as follows:
function filterData(rowData, userInput) {
return rowData.filter((row) => {
return row.name.toLowerCase().includes(userInput.toLowerCase());
});
}
You will then only ever use the variable sortedAndFilteredData
and not the
rowData
state.
By the way, we filter the data before sorting, so the sorting is done on the filtered data. This will result in a performance improvement, as the sorting algorithm will only be called on the filtered data.