I’ve made plenty of website: to book hotel rooms, to buy clothes, to list stock items… All of them had a similar component. A table to display the items. Creating a table in the frontend may be simple, just add the table HTML element, the header element (to let know the users about the columns), and plenty of rows.
The problems comes in two ways: too many rows and/or too many columns. The problem with too many columns is that the request to join all the data may be complex and expensive. But this is a backend and database problem for another day.
The problems that impact the frontend side are: how to display as many rows as possible without saturating the browser memory, how to create a sort system, how to create a filter system.
Let’s address those problems and all the solutions I’ve implemented.
Create My Own Table Component
First of all, my frontend may be in ReactJS or Angular, an simple HTML table isn’t enough. I need a richer component more interactive. I may need buttons inside the rows, I may buttons in the header to sort or filter, some rows may be links, and the design of a simple HTML isn’t always what the more sexy.
Let’s continue with ReactJS, and I’ll need mobX for the store management. So let’s create a CustomTableComponent.
import React from 'react';interface Props { items: any[]; columnConsumers: ((item: any) => React.ReactNode)[]; onRequestEdit?: (id: number) => void; onRequestDelete?: (id: number) => void; rowUrl?: string;}const Table: React.FC<Props> = ({ items, columnConsumers, onRequestEdit, onRequestDelete, rowUrl,}) => { return ( <table> <tbody> {items.map((item) => ( <tr key={item.id}> {columnConsumers.map((consumer, idx) => ( <td key={idx}> {rowUrl ? ( <a href={`${rowUrl}${item.id}`}>{consumer(item)}</a> ) : ( consumer(item) )} </td> ))} {(onRequestEdit || onRequestDelete) && ( <td> {onRequestEdit && ( <button onClick={() => onRequestEdit(item.id)}>Edit</button> )} {onRequestDelete && ( <button onClick={() => onRequestDelete(item.id)}>Delete</button> )} </td> )} </tr> ))} </tbody> </table> );};export default Table;
From here, let’s address the other problems and enrich the component as needed.
Use Server-Side Pagination and Sorting
First of all, your backend must be prepared for pagination and a sort order as described in this article. Fetching less data means faster response time and less memory consumption in the browser (we already have 30 tabs opened, don’t let the 31 freeze your browser).
Now that my backend returns me only a page of the data, I need to add the pagination buttons and actions on the columns to manage the order. I can add the pagination inside the Table component, but I may want to display the pagination elsewhere. So let’s create a dedicated component.
And how do I communicate the Table component with the Pagination component? With a PaginationStore. Let’s start by the store.
import { makeAutoObservable } from 'mobx';export class PaginationStore { currentPage = 0; maxPages = 1; sortByColumnIndex: number | null = null; sortOrder = 'asc'; constructor() { makeAutoObservable(this); } setMaxPages(max: number) { this.maxPages = max; if (this.currentPage > max) { this.currentPage = max; } } nextPage() { this.currentPage++; } prevPage() { this.currentPage = Math.max(0, this.currentPage - 1); } setPage(page: number) { const clampedPage = Math.max(0, Math.min(page, this.maxPages)); this.currentPage = clampedPage; } setOrder(order: string) { this.sortOrder = order; this.currentPage = 0; } setSort(columnIndex: number) { if (this.sortByColumnIndex === columnIndex) { this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc'; } else { this.sortByColumnIndex = columnIndex; this.sortOrder = 'asc'; } this.currentPage = 0; } reset() { this.currentPage = 0; this.maxPages = 1; this.sortByColumnIndex = null; this.sortOrder = 'asc'; }}
Let’s go now with the Pagination component.
import { useEffect, useState } from 'react';import { observer } from 'mobx-react-lite';import './Pagination.scss';import { PaginationStore } from 'src/stores/PaginationStore';type PaginationProps = { store: PaginationStore;};const Pagination = observer(({ store }: PaginationProps) => { const [enablePreviousButton, setEnablePreviousButton] = useState(false); const [enableNextButton, setEnableNextButton] = useState(true); useEffect(() => { setEnablePreviousButton(store.currentPage > 0); setEnableNextButton(store.currentPage + 1 < store.maxPages); }, [store.currentPage, store.maxPages]); return ( <div className="pagination-block"> <button onClick={() => store.prevPage()} disabled={!enablePreviousButton} >Previous</button> <div className="pagination-text"> {t('pagination.text', { current: store.currentPage + 1, last: store.maxPages, })} </div> <button onClick={() => store.nextPage()} disabled={!enableNextButton} >Next</button> </div> );});export default Pagination;
I choose to add the PaginationStore in the input parameters for many reasons:
- it’s easier to test;
- I may want to display multiple tables in the same table;
- I may want to display some tables with no pagination.
Let’s now take a look to the Table component and how it changed.
import React from 'react';import { observer } from 'mobx-react-lite';import { PaginationStore } from 'src/stores/PaginationStore';interface Props { items: any[]; columnConsumers: ((item: any) => React.ReactNode)[]; columnNames?: string[]; onRequestEdit?: (id: number) => void; onRequestDelete?: (id: number) => void; rowUrl?: string; paginationStore: PaginationStore;}const Table: React.FC<Props> = observer(({ items, columnConsumers, columnNames, onRequestEdit, onRequestDelete, rowUrl, paginationStore,}) => { const handleSort = (index: number) => { paginationStore.setSort(index); }; return ( <table> {columnNames && ( <thead> <tr> {columnNames.map((name, idx) => ( <th key={idx} onClick={() => handleSort(idx)} style={{ cursor: 'pointer' }}> {name} {paginationStore.sortByColumnIndex === idx && ( <span>{paginationStore.sortOrder === 'asc' ? ' ▲' : ' ▼'}</span> )} </th> ))} {(onRequestEdit || onRequestDelete) && <th />} </tr> </thead> )} <tbody> {items.map((item) => ( <tr key={item.id}> {columnConsumers.map((consumer, idx) => ( <td key={idx}> {rowUrl ? ( <a href={`${rowUrl}${item.id}`}>{consumer(item)}</a> ) : ( consumer(item) )} </td> ))} {(onRequestEdit || onRequestDelete) && ( <td> {onRequestEdit && ( <button onClick={() => onRequestEdit(item.id)}>Edit</button> )} {onRequestDelete && ( <button onClick={() => onRequestDelete(item.id)}>Delete</button> )} </td> )} </tr> ))} </tbody> </table> );});export default Table;
Let’s put this all together now:
import React, { useEffect, useState, useMemo } from 'react';import { observer } from 'mobx-react-lite';import { PaginationStore } from 'src/stores/PaginationStore';import Table from './Table';import Pagination from './Pagination';interface User { id: number; name: string; email: string; role: string;}const UserList: React.FC = observer(() => { const [users, setUsers] = useState<User[]>([]); const [loading, setLoading] = useState(false); const paginationStore = useMemo(() => new PaginationStore(), []); useEffect(() => { const fetchData = async () => { setLoading(true); try { const response = await fetch( `/api/users?page=${paginationStore.currentPage}&sortColumn=${paginationStore.sortByColumnIndex ?? ''}&sortOrder=${paginationStore.sortOrder}` ); const data = await response.json(); setUsers(data.items); paginationStore.setMaxPages(data.totalPages); } finally { setLoading(false); } }; fetchData(); }, [ paginationStore.currentPage, paginationStore.sortByColumnIndex, paginationStore.sortOrder, ]); const columnConsumers = [ (user: User) => user.name, (user: User) => user.email, (user: User) => user.role, ]; return ( <div> {loading ? ( <p>Loading...</p> ) : ( <> <Table items={users} columnNames={['Name', 'Email', 'Role']} columnConsumers={columnConsumers} paginationStore={paginationStore} onRequestEdit={(id) => console.log('Edit', id)} onRequestDelete={(id) => console.log('Delete', id)} rowUrl="/users/" /> {paginationStore.maxPages > 1 && ( <Pagination store={paginationStore} /> )} </> )} </div> );});export default UserList;
That’s two of the three problems solved. The filter system — and the auto-complete search that comes with it — is next, and it’s where the PaginationStore approach gets tested the most.


Leave a comment