Building Sortable Data Tables with React MUI and Zustand: A Comprehensive Guide
Data tables are a fundamental part of many web applications, allowing users to interact with large datasets in a structured manner. Material-UI (MUI) provides powerful components for building data tables, and when combined with state management libraries like Zustand, you can create efficient, feature-rich tables with minimal boilerplate.
In this guide, I'll walk you through creating a sortable data table with pagination using MUI's Table components and Zustand for state management. By the end, you'll understand how to implement sorting, pagination, and efficient state management for your data tables.
Learning Objectives
After reading this article, you will be able to:
- Implement a sortable data table using MUI's Table components
- Add pagination functionality to handle large datasets
- Use Zustand for efficient state management of table data and UI state
- Customize MUI tables for your specific requirements
- Handle common edge cases and performance issues in data tables
Understanding MUI Table Components
Before diving into the implementation, let's understand the key MUI Table components we'll be using.
Core Table Components
MUI provides a suite of components that follow the HTML table structure but with enhanced styling and functionality:
Table
- The root component that wraps all table elementsTableHead
- Container for header row(s)TableBody
- Container for data rowsTableRow
- Represents a table rowTableCell
- Represents a table cellTablePagination
- Adds pagination controls to the tableTableSortLabel
- Adds sorting functionality to table headers
These components work together to create a cohesive table experience while maintaining the semantic structure of HTML tables.
Table Component Props
The Table
component comes with several props that allow you to customize its appearance and behavior:
Prop | Type | Default | Description |
---|---|---|---|
children | node | - | Table contents, usually TableHead and TableBody |
component | elementType | 'table' | The component used for the root node |
padding | 'normal' | 'checkbox' | 'none' | 'normal' | Sets the padding for table cells |
size | 'small' | 'medium' | 'medium' | Defines the density of the table |
stickyHeader | bool | false | If true, the TableHead will remain fixed at the top |
sx | object | - | The system prop that allows defining system overrides |
Similarly, each of the other table components (TableHead
, TableBody
, etc.) has its own set of props that allow for customization.
Customization Options
MUI tables can be customized in several ways:
- Using the
sx
prop: The most direct way to apply styles to MUI components. - Theme customization: Applying global styles through theme customization.
- Styled components: Using MUI's
styled
API to create custom styled table components. - CSS overrides: Using CSS classes to override default styles.
For example, you can use the sx
prop to apply custom styles to a table:
<Table
sx={{
minWidth: 650,
'& .MuiTableCell-root': {
borderBottom: '1px solid rgba(224, 224, 224, 0.5)'
}
}}
>
{/* Table content */}
</Table>
Accessibility Features
MUI tables come with built-in accessibility features, but you should be aware of a few best practices:
- Use
<TableHead>
for header rows to ensure proper semantic structure - Include
aria-sort
attributes on sortable columns (handled byTableSortLabel
) - Ensure proper color contrast for text and background
- Use
aria-label
for pagination controls when necessary
Introduction to Zustand
Zustand is a small, fast, and scalable state management library for React. It's simpler than Redux but more powerful than React's built-in state management. For our data table, Zustand will help us manage:
- The current table data
- Sorting state (column and direction)
- Pagination state (page number and rows per page)
Why Zustand for Table State Management?
There are several reasons to choose Zustand for managing table state:
- Simplicity: Zustand has a minimal API that's easy to learn and use
- Performance: It's optimized for performance with minimal re-renders
- No boilerplate: Unlike Redux, Zustand requires minimal setup code
- Hooks-based: Works naturally with React's hooks system
- Devtools support: Integrates with Redux DevTools for debugging
Setting Up the Project
Let's start by setting up a new React project and installing the necessary dependencies.
Creating a New React Project
First, let's create a new React project using Create React App:
npx create-react-app mui-table-zustand
cd mui-table-zustand
Installing Dependencies
Now, let's install the required dependencies:
npm install @mui/material @mui/icons-material @emotion/react @emotion/styled zustand
This installs:
- MUI core components and icons
- Emotion (required by MUI for styling)
- Zustand for state management
Creating the Table Store with Zustand
Before implementing the UI components, let's create our Zustand store to manage the table state.
Setting Up the Zustand Store
Let's create a file called tableStore.js
in the src
directory:
// src/tableStore.js
import { create } from 'zustand';
// Sample data for our table
const createInitialData = () => {
return Array.from({ length: 50 }, (_, index) => ({
id: index + 1,
name: `Item ${index + 1}`,
calories: Math.floor(Math.random() * 500),
fat: Math.floor(Math.random() * 50),
carbs: Math.floor(Math.random() * 100),
protein: Math.floor(Math.random() * 30),
}));
};
// Function to sort data based on a property and direction
const sortData = (data, property, direction) => {
return [...data].sort((a, b) => {
const aValue = a[property];
const bValue = b[property];
if (direction === 'asc') {
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
} else {
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
}
});
};
// Create the store
const useTableStore = create((set) => ({
// Table data and metadata
data: createInitialData(),
filteredData: createInitialData(),
// Sorting state
orderBy: 'name',
orderDirection: 'asc',
// Pagination state
page: 0,
rowsPerPage: 10,
// Actions
setOrder: (property) => set((state) => {
const isAsc = state.orderBy === property && state.orderDirection === 'asc';
const newDirection = isAsc ? 'desc' : 'asc';
const sortedData = sortData(state.filteredData, property, newDirection);
return {
orderBy: property,
orderDirection: newDirection,
filteredData: sortedData,
};
}),
setPage: (newPage) => set({ page: newPage }),
setRowsPerPage: (newRowsPerPage) => set({
rowsPerPage: newRowsPerPage,
page: 0 // Reset to first page when changing rows per page
}),
// For filtering (if needed later)
filterData: (searchTerm) => set((state) => {
if (!searchTerm) {
return { filteredData: state.data, page: 0 };
}
const filtered = state.data.filter((item) =>
item.name.toLowerCase().includes(searchTerm.toLowerCase())
);
return {
filteredData: sortData(filtered, state.orderBy, state.orderDirection),
page: 0 // Reset to first page when filtering
};
}),
}));
export default useTableStore;
Let's break down this store:
-
State:
data
andfilteredData
hold our table dataorderBy
andorderDirection
track sorting statepage
androwsPerPage
manage pagination
-
Actions:
setOrder
handles sorting logicsetPage
andsetRowsPerPage
handle paginationfilterData
provides filtering capability (optional)
-
Helper Functions:
createInitialData
generates sample datasortData
sorts the data based on a property and direction
Building the Sortable Data Table Component
Now, let's create the main table component that uses our Zustand store.
Creating the Table Component
Let's create a file called SortableTable.js
in the src
directory:
// src/SortableTable.js
import React from 'react';
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TablePagination,
TableSortLabel,
Paper,
Box,
} from '@mui/material';
import useTableStore from './tableStore';
const SortableTable = () => {
// Get state and actions from our Zustand store
const {
filteredData,
orderBy,
orderDirection,
page,
rowsPerPage,
setOrder,
setPage,
setRowsPerPage,
} = useTableStore();
// Get only the data for the current page
const paginatedData = filteredData.slice(
page * rowsPerPage,
page * rowsPerPage + rowsPerPage
);
// Handle sort request
const handleRequestSort = (property) => {
setOrder(property);
};
// Handle page change
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
// Handle rows per page change
const handleChangeRowsPerPage = (event) => {
setRowsPerPage(parseInt(event.target.value, 10));
};
// Table headers configuration
const headCells = [
{ id: 'name', label: 'Name' },
{ id: 'calories', label: 'Calories' },
{ id: 'fat', label: 'Fat (g)' },
{ id: 'carbs', label: 'Carbs (g)' },
{ id: 'protein', label: 'Protein (g)' },
];
return (
<Box sx={{ width: '100%' }}>
<Paper sx={{ width: '100%', mb: 2 }}>
<TableContainer>
<Table
sx={{ minWidth: 750 }}
aria-labelledby="tableTitle"
size="medium"
>
<TableHead>
<TableRow>
{headCells.map((headCell) => (
<TableCell
key={headCell.id}
sortDirection={orderBy === headCell.id ? orderDirection : false}
>
<TableSortLabel
active={orderBy === headCell.id}
direction={orderBy === headCell.id ? orderDirection : 'asc'}
onClick={() => handleRequestSort(headCell.id)}
>
{headCell.label}
</TableSortLabel>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{paginatedData.map((row) => (
<TableRow
hover
key={row.id}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
<TableCell component="th" scope="row">
{row.name}
</TableCell>
<TableCell>{row.calories}</TableCell>
<TableCell>{row.fat}</TableCell>
<TableCell>{row.carbs}</TableCell>
<TableCell>{row.protein}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<TablePagination
rowsPerPageOptions={[5, 10, 25]}
component="div"
count={filteredData.length}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
/>
</Paper>
</Box>
);
};
export default SortableTable;
This component:
- Pulls state and actions from our Zustand store
- Renders a table with sortable columns using
TableSortLabel
- Implements pagination with
TablePagination
- Calculates which rows to display based on the current page and rows per page
Adding the Table to the App
Now, let's update App.js
to use our table component:
// src/App.js
import React from 'react';
import { Container, Typography, Box } from '@mui/material';
import SortableTable from './SortableTable';
function App() {
return (
<Container maxWidth="lg">
<Box sx={{ my: 4 }}>
<Typography variant="h4" component="h1" gutterBottom>
MUI Sortable Table with Zustand
</Typography>
<SortableTable />
</Box>
</Container>
);
}
export default App;
Adding Search Functionality
Let's enhance our table by adding search functionality. We'll update our table component to include a search field that filters the data.
Updating the Table Component with Search
// src/SortableTable.js
import React, { useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TablePagination,
TableSortLabel,
Paper,
Box,
TextField,
InputAdornment,
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import useTableStore from './tableStore';
const SortableTable = () => {
// Local state for search input
const [searchTerm, setSearchTerm] = useState('');
// Get state and actions from our Zustand store
const {
filteredData,
orderBy,
orderDirection,
page,
rowsPerPage,
setOrder,
setPage,
setRowsPerPage,
filterData,
} = useTableStore();
// Get only the data for the current page
const paginatedData = filteredData.slice(
page * rowsPerPage,
page * rowsPerPage + rowsPerPage
);
// Handle sort request
const handleRequestSort = (property) => {
setOrder(property);
};
// Handle page change
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
// Handle rows per page change
const handleChangeRowsPerPage = (event) => {
setRowsPerPage(parseInt(event.target.value, 10));
};
// Handle search input change
const handleSearchChange = (event) => {
const value = event.target.value;
setSearchTerm(value);
filterData(value);
};
// Table headers configuration
const headCells = [
{ id: 'name', label: 'Name' },
{ id: 'calories', label: 'Calories' },
{ id: 'fat', label: 'Fat (g)' },
{ id: 'carbs', label: 'Carbs (g)' },
{ id: 'protein', label: 'Protein (g)' },
];
return (
<Box sx={{ width: '100%' }}>
<Paper sx={{ width: '100%', mb: 2 }}>
<Box sx={{ p: 2 }}>
<TextField
fullWidth
placeholder="Search by name..."
value={searchTerm}
onChange={handleSearchChange}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
</Box>
<TableContainer>
<Table
sx={{ minWidth: 750 }}
aria-labelledby="tableTitle"
size="medium"
>
<TableHead>
<TableRow>
{headCells.map((headCell) => (
<TableCell
key={headCell.id}
sortDirection={orderBy === headCell.id ? orderDirection : false}
>
<TableSortLabel
active={orderBy === headCell.id}
direction={orderBy === headCell.id ? orderDirection : 'asc'}
onClick={() => handleRequestSort(headCell.id)}
>
{headCell.label}
</TableSortLabel>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{paginatedData.length > 0 ? (
paginatedData.map((row) => (
<TableRow
hover
key={row.id}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
<TableCell component="th" scope="row">
{row.name}
</TableCell>
<TableCell>{row.calories}</TableCell>
<TableCell>{row.fat}</TableCell>
<TableCell>{row.carbs}</TableCell>
<TableCell>{row.protein}</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} align="center">
No results found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
rowsPerPageOptions={[5, 10, 25]}
component="div"
count={filteredData.length}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
/>
</Paper>
</Box>
);
};
export default SortableTable;
We've added:
- A search input field with an icon
- Local state to track the search term
- A handler to update the search term and filter the data
- A "No results found" message when the filtered data is empty
Adding Loading and Error States
Real-world applications often need to handle loading and error states when fetching data. Let's update our Zustand store and table component to handle these states.
Updating the Zustand Store
// src/tableStore.js
import { create } from 'zustand';
// Sample data for our table
const createInitialData = () => {
return Array.from({ length: 50 }, (_, index) => ({
id: index + 1,
name: `Item ${index + 1}`,
calories: Math.floor(Math.random() * 500),
fat: Math.floor(Math.random() * 50),
carbs: Math.floor(Math.random() * 100),
protein: Math.floor(Math.random() * 30),
}));
};
// Function to sort data based on a property and direction
const sortData = (data, property, direction) => {
return [...data].sort((a, b) => {
const aValue = a[property];
const bValue = b[property];
if (direction === 'asc') {
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
} else {
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
}
});
};
// Create the store
const useTableStore = create((set) => ({
// Table data and metadata
data: [],
filteredData: [],
// Sorting state
orderBy: 'name',
orderDirection: 'asc',
// Pagination state
page: 0,
rowsPerPage: 10,
// Loading and error states
loading: false,
error: null,
// Actions
fetchData: async () => {
set({ loading: true, error: null });
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
const data = createInitialData();
set({
data,
filteredData: data,
loading: false
});
} catch (error) {
set({
error: "Failed to fetch data",
loading: false
});
}
},
setOrder: (property) => set((state) => {
const isAsc = state.orderBy === property && state.orderDirection === 'asc';
const newDirection = isAsc ? 'desc' : 'asc';
const sortedData = sortData(state.filteredData, property, newDirection);
return {
orderBy: property,
orderDirection: newDirection,
filteredData: sortedData,
};
}),
setPage: (newPage) => set({ page: newPage }),
setRowsPerPage: (newRowsPerPage) => set({
rowsPerPage: newRowsPerPage,
page: 0 // Reset to first page when changing rows per page
}),
filterData: (searchTerm) => set((state) => {
if (!searchTerm) {
return { filteredData: state.data, page: 0 };
}
const filtered = state.data.filter((item) =>
item.name.toLowerCase().includes(searchTerm.toLowerCase())
);
return {
filteredData: sortData(filtered, state.orderBy, state.orderDirection),
page: 0 // Reset to first page when filtering
};
}),
}));
export default useTableStore;
Now, let's update our table component to handle these states:
// src/SortableTable.js
import React, { useState, useEffect } from 'react';
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TablePagination,
TableSortLabel,
Paper,
Box,
TextField,
InputAdornment,
CircularProgress,
Alert,
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import useTableStore from './tableStore';
const SortableTable = () => {
// Local state for search input
const [searchTerm, setSearchTerm] = useState('');
// Get state and actions from our Zustand store
const {
filteredData,
orderBy,
orderDirection,
page,
rowsPerPage,
loading,
error,
fetchData,
setOrder,
setPage,
setRowsPerPage,
filterData,
} = useTableStore();
// Fetch data on component mount
useEffect(() => {
fetchData();
}, [fetchData]);
// Get only the data for the current page
const paginatedData = filteredData.slice(
page * rowsPerPage,
page * rowsPerPage + rowsPerPage
);
// Handle sort request
const handleRequestSort = (property) => {
setOrder(property);
};
// Handle page change
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
// Handle rows per page change
const handleChangeRowsPerPage = (event) => {
setRowsPerPage(parseInt(event.target.value, 10));
};
// Handle search input change
const handleSearchChange = (event) => {
const value = event.target.value;
setSearchTerm(value);
filterData(value);
};
// Table headers configuration
const headCells = [
{ id: 'name', label: 'Name' },
{ id: 'calories', label: 'Calories' },
{ id: 'fat', label: 'Fat (g)' },
{ id: 'carbs', label: 'Carbs (g)' },
{ id: 'protein', label: 'Protein (g)' },
];
return (
<Box sx={{ width: '100%' }}>
<Paper sx={{ width: '100%', mb: 2 }}>
<Box sx={{ p: 2 }}>
<TextField
fullWidth
placeholder="Search by name..."
value={searchTerm}
onChange={handleSearchChange}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
</Box>
{error && (
<Box sx={{ p: 2 }}>
<Alert severity="error">{error}</Alert>
</Box>
)}
<TableContainer>
<Table
sx={{ minWidth: 750 }}
aria-labelledby="tableTitle"
size="medium"
>
<TableHead>
<TableRow>
{headCells.map((headCell) => (
<TableCell
key={headCell.id}
sortDirection={orderBy === headCell.id ? orderDirection : false}
>
<TableSortLabel
active={orderBy === headCell.id}
direction={orderBy === headCell.id ? orderDirection : 'asc'}
onClick={() => handleRequestSort(headCell.id)}
>
{headCell.label}
</TableSortLabel>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={5} align="center" sx={{ py: 3 }}>
<CircularProgress />
</TableCell>
</TableRow>
) : paginatedData.length > 0 ? (
paginatedData.map((row) => (
<TableRow
hover
key={row.id}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
<TableCell component="th" scope="row">
{row.name}
</TableCell>
<TableCell>{row.calories}</TableCell>
<TableCell>{row.fat}</TableCell>
<TableCell>{row.carbs}</TableCell>
<TableCell>{row.protein}</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} align="center">
No results found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
rowsPerPageOptions={[5, 10, 25]}
component="div"
count={filteredData.length}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
/>
</Paper>
</Box>
);
};
export default SortableTable;
We've added:
- Loading state with a
CircularProgress
indicator - Error state with an
Alert
component - A
useEffect
hook to fetch data when the component mounts
Advanced Table Customization
Let's enhance our table with some advanced customization options.
Adding Row Selection
// First, update the store to handle selection
// src/tableStore.js (add to the existing store)
const useTableStore = create((set) => ({
// ... existing store code
// Selection state
selected: [],
// Selection actions
selectRow: (id) => set((state) => {
const selectedIndex = state.selected.indexOf(id);
let newSelected = [];
if (selectedIndex === -1) {
newSelected = [...state.selected, id];
} else {
newSelected = state.selected.filter((selectedId) => selectedId !== id);
}
return { selected: newSelected };
}),
selectAllRows: () => set((state) => {
if (state.selected.length === state.filteredData.length) {
return { selected: [] };
} else {
return { selected: state.filteredData.map((row) => row.id) };
}
}),
clearSelection: () => set({ selected: [] }),
}));
Now, let's update the table component to include selection:
// src/SortableTable.js
import React, { useState, useEffect } from 'react';
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TablePagination,
TableSortLabel,
Paper,
Box,
TextField,
InputAdornment,
CircularProgress,
Alert,
Checkbox,
Toolbar,
Typography,
IconButton,
Tooltip,
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import DeleteIcon from '@mui/icons-material/Delete';
import FilterListIcon from '@mui/icons-material/FilterList';
import useTableStore from './tableStore';
const SortableTable = () => {
// Local state for search input
const [searchTerm, setSearchTerm] = useState('');
// Get state and actions from our Zustand store
const {
filteredData,
orderBy,
orderDirection,
page,
rowsPerPage,
loading,
error,
selected,
fetchData,
setOrder,
setPage,
setRowsPerPage,
filterData,
selectRow,
selectAllRows,
clearSelection,
} = useTableStore();
// Fetch data on component mount
useEffect(() => {
fetchData();
}, [fetchData]);
// Get only the data for the current page
const paginatedData = filteredData.slice(
page * rowsPerPage,
page * rowsPerPage + rowsPerPage
);
// Handle sort request
const handleRequestSort = (property) => {
setOrder(property);
};
// Handle page change
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
// Handle rows per page change
const handleChangeRowsPerPage = (event) => {
setRowsPerPage(parseInt(event.target.value, 10));
};
// Handle search input change
const handleSearchChange = (event) => {
const value = event.target.value;
setSearchTerm(value);
filterData(value);
};
// Handle row click for selection
const handleRowClick = (id) => {
selectRow(id);
};
// Handle select all rows
const handleSelectAllClick = () => {
selectAllRows();
};
// Check if a row is selected
const isSelected = (id) => selected.indexOf(id) !== -1;
// Table headers configuration
const headCells = [
{ id: 'name', label: 'Name' },
{ id: 'calories', label: 'Calories' },
{ id: 'fat', label: 'Fat (g)' },
{ id: 'carbs', label: 'Carbs (g)' },
{ id: 'protein', label: 'Protein (g)' },
];
return (
<Box sx={{ width: '100%' }}>
<Paper sx={{ width: '100%', mb: 2 }}>
<Toolbar
sx={{
pl: { sm: 2 },
pr: { xs: 1, sm: 1 },
...(selected.length > 0 && {
bgcolor: (theme) =>
theme.palette.mode === 'light'
? 'rgba(25, 118, 210, 0.08)'
: 'rgba(25, 118, 210, 0.16)',
}),
}}
>
{selected.length > 0 ? (
<Typography
sx={{ flex: '1 1 100%' }}
color="inherit"
variant="subtitle1"
component="div"
>
{selected.length} selected
</Typography>
) : (
<Typography
sx={{ flex: '1 1 100%' }}
variant="h6"
id="tableTitle"
component="div"
>
Nutrition Data
</Typography>
)}
{selected.length > 0 ? (
<Tooltip title="Delete">
<IconButton>
<DeleteIcon />
</IconButton>
</Tooltip>
) : (
<Tooltip title="Filter list">
<IconButton>
<FilterListIcon />
</IconButton>
</Tooltip>
)}
</Toolbar>
<Box sx={{ p: 2 }}>
<TextField
fullWidth
placeholder="Search by name..."
value={searchTerm}
onChange={handleSearchChange}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
</Box>
{error && (
<Box sx={{ p: 2 }}>
<Alert severity="error">{error}</Alert>
</Box>
)}
<TableContainer>
<Table
sx={{ minWidth: 750 }}
aria-labelledby="tableTitle"
size="medium"
>
<TableHead>
<TableRow>
<TableCell padding="checkbox">
<Checkbox
color="primary"
indeterminate={selected.length > 0 && selected.length < filteredData.length}
checked={filteredData.length > 0 && selected.length === filteredData.length}
onChange={handleSelectAllClick}
inputProps={{
'aria-label': 'select all desserts',
}}
/>
</TableCell>
{headCells.map((headCell) => (
<TableCell
key={headCell.id}
sortDirection={orderBy === headCell.id ? orderDirection : false}
>
<TableSortLabel
active={orderBy === headCell.id}
direction={orderBy === headCell.id ? orderDirection : 'asc'}
onClick={() => handleRequestSort(headCell.id)}
>
{headCell.label}
</TableSortLabel>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} align="center" sx={{ py: 3 }}>
<CircularProgress />
</TableCell>
</TableRow>
) : paginatedData.length > 0 ? (
paginatedData.map((row) => {
const isItemSelected = isSelected(row.id);
return (
<TableRow
hover
onClick={() => handleRowClick(row.id)}
role="checkbox"
aria-checked={isItemSelected}
tabIndex={-1}
key={row.id}
selected={isItemSelected}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
<TableCell padding="checkbox">
<Checkbox
color="primary"
checked={isItemSelected}
inputProps={{
'aria-labelledby': `table-checkbox-${row.id}`,
}}
/>
</TableCell>
<TableCell component="th" scope="row" id={`table-checkbox-${row.id}`}>
{row.name}
</TableCell>
<TableCell>{row.calories}</TableCell>
<TableCell>{row.fat}</TableCell>
<TableCell>{row.carbs}</TableCell>
<TableCell>{row.protein}</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={6} align="center">
No results found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
rowsPerPageOptions={[5, 10, 25]}
component="div"
count={filteredData.length}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
/>
</Paper>
</Box>
);
};
export default SortableTable;
We've added:
- Checkboxes for row selection
- A toolbar that changes based on selection state
- Visual indicators for selected rows
- Select all functionality
Adding Sticky Headers
For tables with many rows, sticky headers can improve usability:
// Update the Table component in SortableTable.js
<TableContainer sx={{ maxHeight: 440 }}>
<Table
sx={{ minWidth: 750 }}
aria-labelledby="tableTitle"
size="medium"
stickyHeader
>
{/* Table content */}
</Table>
</TableContainer>
The stickyHeader
prop and maxHeight
on the container work together to create a scrollable table with fixed headers.
Performance Optimization
For large datasets, we need to consider performance optimizations.
Virtualization for Large Datasets
When dealing with thousands of rows, virtualization can significantly improve performance by only rendering the rows that are visible in the viewport.
Let's integrate the react-window
library for virtualization:
// First, install react-window
// npm install react-window
// Then update the table component
import { FixedSizeList as List } from 'react-window';
// Inside the TableBody section of SortableTable.js
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} align="center" sx={{ py: 3 }}>
<CircularProgress />
</TableCell>
</TableRow>
) : filteredData.length > 0 ? (
<List
height={400}
width="100%"
itemCount={filteredData.length}
itemSize={53} // Approximate height of a row
itemData={{
data: filteredData,
isSelected,
handleRowClick,
columns: headCells,
}}
>
{({ index, style, data }) => {
const row = data.data[index];
const isItemSelected = data.isSelected(row.id);
return (
<TableRow
hover
onClick={() => data.handleRowClick(row.id)}
role="checkbox"
aria-checked={isItemSelected}
tabIndex={-1}
key={row.id}
selected={isItemSelected}
style={{ ...style, display: 'flex' }}
>
<TableCell padding="checkbox" style={{ flex: '0 0 auto' }}>
<Checkbox
color="primary"
checked={isItemSelected}
inputProps={{
'aria-labelledby': `table-checkbox-${row.id}`,
}}
/>
</TableCell>
<TableCell component="th" scope="row" id={`table-checkbox-${row.id}`} style={{ flex: 1 }}>
{row.name}
</TableCell>
<TableCell style={{ flex: 1 }}>{row.calories}</TableCell>
<TableCell style={{ flex: 1 }}>{row.fat}</TableCell>
<TableCell style={{ flex: 1 }}>{row.carbs}</TableCell>
<TableCell style={{ flex: 1 }}>{row.protein}</TableCell>
</TableRow>
);
}}
</List>
) : (
<TableRow>
<TableCell colSpan={6} align="center">
No results found
</TableCell>
</TableRow>
)}
</TableBody>
This approach only renders the rows that are currently visible in the viewport, which significantly improves performance for large datasets.
Debouncing Search Input
To prevent excessive filtering operations during typing, we can debounce the search input:
// First, install lodash.debounce
// npm install lodash.debounce
// Then update the search handler in SortableTable.js
import debounce from 'lodash.debounce';
import { useCallback } from 'react';
// Inside the SortableTable component
const debouncedFilterData = useCallback(
debounce((value) => {
filterData(value);
}, 300),
[filterData]
);
const handleSearchChange = (event) => {
const value = event.target.value;
setSearchTerm(value);
debouncedFilterData(value);
};
This prevents the filtering operation from running on every keystroke, which can be expensive for large datasets.
Best Practices and Common Issues
Let's discuss some best practices and common issues when working with MUI tables and Zustand.
Best Practices
- Separate Data Fetching from UI Logic
Keep your data fetching logic in the Zustand store, separate from your UI components. This makes it easier to test and maintain.
- Use Optimistic Updates
For operations like deletion or editing, consider using optimistic updates to improve perceived performance:
// In your store
deleteRow: (id) => {
// First, update the UI optimistically
set((state) => ({
data: state.data.filter(row => row.id !== id),
filteredData: state.filteredData.filter(row => row.id !== id),
selected: state.selected.filter(selectedId => selectedId !== id)
}));
// Then, perform the actual API call
api.deleteRow(id).catch(error => {
// If the API call fails, revert the change
console.error('Failed to delete row:', error);
fetchData(); // Re-fetch the data
});
}
- Memoize Expensive Calculations
If you have expensive calculations, use useMemo
to avoid recalculating on every render:
// Inside your component
const sortedData = useMemo(() => {
return [...data].sort((a, b) => {
// Expensive sorting logic
});
}, [data, orderBy, orderDirection]);
- Use Server-Side Pagination for Very Large Datasets
If you're dealing with thousands of records, consider implementing server-side pagination and sorting:
// In your store
fetchData: async (page, rowsPerPage, orderBy, orderDirection) => {
set({ loading: true, error: null });
try {
const response = await api.getData({
page,
limit: rowsPerPage,
sort: orderBy,
order: orderDirection,
});
set({
data: response.data,
filteredData: response.data,
totalCount: response.totalCount, // Total count from the server
loading: false
});
} catch (error) {
set({
error: "Failed to fetch data",
loading: false
});
}
}
Common Issues and Solutions
- Table Width Issues
Problem: Table columns don't align properly or resize unexpectedly.
Solution: Use fixed column widths and the stickyHeader
prop:
<TableContainer>
<Table stickyHeader>
<TableHead>
<TableRow>
<TableCell width={100}>ID</TableCell>
<TableCell width={200}>Name</TableCell>
<TableCell width={150}>Calories</TableCell>
{/* Other cells */}
</TableRow>
</TableHead>
{/* Table body */}
</Table>
</TableContainer>
- Performance Issues with Large Datasets
Problem: The table becomes slow with thousands of rows.
Solution: Use virtualization (as shown earlier) or implement server-side pagination.
- Complex Filtering Logic
Problem: You need to filter on multiple columns with different conditions.
Solution: Implement a more flexible filtering system in your store:
// In your store
setFilters: (filters) => set((state) => {
const filteredData = state.data.filter(row => {
// Check each filter
return Object.entries(filters).every(([key, value]) => {
if (!value) return true; // Skip empty filters
const rowValue = row[key];
// Handle different types of filters
if (typeof value === 'string') {
return rowValue.toLowerCase().includes(value.toLowerCase());
} else if (typeof value === 'object' && value.min !== undefined && value.max !== undefined) {
// Range filter
return rowValue >= value.min && rowValue <= value.max;
} else if (Array.isArray(value)) {
// Multi-select filter
return value.includes(rowValue);
}
return true;
});
});
return {
filters,
filteredData,
page: 0 // Reset to first page when filtering
};
})
- Accessibility Issues
Problem: The table is not fully accessible to screen readers.
Solution: Ensure proper ARIA attributes and keyboard navigation:
<Table aria-label="Data table">
<TableHead>
<TableRow>
<TableCell>
<TableSortLabel
active={orderBy === 'name'}
direction={orderDirection}
onClick={() => handleSort('name')}
aria-label="Sort by name"
>
Name
</TableSortLabel>
</TableCell>
{/* Other cells */}
</TableRow>
</TableHead>
{/* Table body */}
</Table>
Wrapping Up
In this comprehensive guide, we've built a feature-rich sortable data table using MUI and Zustand. We've covered everything from basic setup to advanced features like row selection, virtualization, and optimistic updates. By separating our state management with Zustand, we've created a maintainable, performant table solution that can handle large datasets and complex interactions.
The combination of MUI's powerful table components and Zustand's simple state management provides a flexible foundation that you can extend to meet your specific requirements. Whether you're building an admin dashboard, a data visualization tool, or any application that needs to display tabular data, this approach will help you create tables that are both functional and user-friendly.