Creating a table with zustand
Intro: What’s a table?
Tables are complex structures. You’re able to sort data, filter data, reformat and reorganize rows, maybe even hide columns and edit formatted cell data. For situations where you want to display tabular data, solutions like simply looping through a 2d-array displaying a small single-page table might be the sufficient way to go:
See the Pen Untitled by Jonathan Hassel (@jotoh98) on CodePen.
But for higher-level features like sorting or filtering the situation changes. Now you’re concerned about an internal data model, pagination, sort and filter inputs, cell output formatting… The list seems to go on and on. Here I want to cover the basics of a table component structure flexible enough to tackle each problem after another. To complete this, you need some basic knowledge of React and hooks.
Data flow in dynamic tables
Let’s start with the basic data flow. To show a current slice of data we can look at two factors limiting the scope in which data is visible: columns and rows.
Column Filtering
Limiting columns is pretty straightforward. For each row that we render (1), we loop over the indices of the columns (2) that we want to show.
Notice how we now have to “abstract” away from the actual data accessing (calling cell
) and instead get the text to render via an index access (3).
See the Pen Simple Table 1 by Jonathan Hassel (@jotoh98) on CodePen.
Row Filtering
Limiting rows is a bit more complicated. We need to know the current page and the number of rows per page. With that information we’re able to calculate the start and end index of the rows we want to show. The workflow is as follows:
- First, we have the full data set that we filter for several criteria. In most cases you want to search for a query but sometimes a date range or a greater-than might be a possible filtering scenario. A simple query filter predicate would look like this:
const filterPredicate = (cellData, query) => cellData.includes(query);
- After filtering we might want to sort the data. In many languages (including js) the sorting functionality is realised by taking two element of a collection (in JS an array) and calculating a comparison number. A sorting function sorting an array in ascending order might look like this:
const sortNumbers = (a, b) => a - b;
That’s it, now we might want to sort other data as well, so for each column that we want to sort we need an according sort function. 3. Finally, we cut the filtered and sorted data in chunks, so-called pages. By doing this we’re able to display only a fraction of the big amount of data at a time and navigate this data with a pagination.
You might be wondering: “Why filter first and sort afterwards?“. You could indeed achieve the same result by switching the two operations.
Let’s look at the runtime. Filtering is a process taking place in O(n)-time, meaning: With n rows you only need to look at each of them once resulting in n decisions/actions taking place.
Sorting on the other hand has a time complexity of O(n log n). By filtering first we reduce the number of rows before passing it to the sort algorithm, why sort more elements than necessary?
Short Intro to Zustand
Zustand is a state management library reducing boilerplate code immensely by making classic reducers obsolete. Like redux, you work with one central state, but unlike redux, you can work with this state much more loosely than incorporating it at a central place in your React application. For a quick and easy demo please visit their cute homepage.
What’s inside our store?
Essential for our table here are their concept of state and methods to alter that state. Let’s begin by collecting the state’s properties. What do we need?
Data Properties
fullData: TRow[]
: an array containing some sort of row. We don’t want to specify one type yet, so we use a genericTRow
. (Notice how I give my generic parameter a name? Watch and repeat!)filteredData: TRow[]
: a cache for the filtered data. Later we may change the filter but we don’t want to loose some rows of our full data set.sortedData: TRow[]
: a cache for the filtered & sorted data. Same reasoning as for thefilteredData
array.currentPage: TRow[]
: a slice of thesortedData
. We may change the page number we’re on pretty fast, but we don’t want to sort and filter again. For ease of use and for caching the slice there iscurrentPage
.
User Input Properties
Moving away from data controlled properties, we enter direct user input:
pageSize: number
: size of the page slice. You can imagine a dropdown with fixed values like 5, 10, 20, all(?).currentPageIndex: number
: the index of the current page. You could also store the starting index of thepageSize
big chunk from thesortedData
array. Just keep track of the currently displayed page .filters: [keyof TRow, string][]
: an array of tuples collecting which column (keyof TRow
) to filter by which query (string
)sorts: [keyof TRow, 'ASC' | 'DESC][]
: array of sorted columns and assigned sort directions
Last but not least we have some configuration properties related to the data we’re translating to table rows.
Let’s say for example we wanted a row entity called Customer
to work with data from our online shop database. The customer type might look something like this:
type Customer = {
firstname: string;
lastname: string;
address: {
street: string;
streetNo: string;
city: string;
postcode: string;
};
orders: number;
refunds: number;
};
Notice how we spiced things up by nesting the address in there? We’ll even be able to work with any sub-structures.