Building a Powerful Paginated Product Table with React MUI Data Grid and Zustand
As a front-end developer, you've likely faced the challenge of creating data-rich tables that need to handle pagination, sorting, and state management efficiently. In my experience, combining MUI's Data Grid with Zustand creates a powerful solution that's both performant and maintainable.
In this guide, I'll walk you through building a comprehensive product table using MUI Data Grid with client-side pagination, all powered by Zustand for state management. You'll learn how to create a solution that's both elegant and scalable, perfect for real-world applications.
Learning Objectives
By the end of this tutorial, you will:
- Understand MUI Data Grid's core features and pagination capabilities
- Implement Zustand for efficient state management of your data grid
- Create a fully functional product table with sorting, filtering, and pagination
- Learn best practices for performance optimization and component customization
- Master advanced features like custom cell rendering and toolbar customization
Understanding MUI Data Grid: A Deep Dive
Before we start coding, let's understand what makes MUI Data Grid a powerful choice for building data-rich applications.
What is MUI Data Grid?
MUI Data Grid is a feature-rich React component for displaying tabular data. It's part of the Material-UI ecosystem and comes in two versions: the community version (free) and the premium version (MUI X Pro/Premium). The Data Grid provides essential features like pagination, sorting, and filtering out of the box, while the premium versions offer advanced capabilities like row grouping, tree data, and Excel export.
For our tutorial, we'll focus on the community version which is powerful enough for most use cases. The Data Grid follows Material Design principles and integrates seamlessly with the rest of the MUI component library.
Key Features of MUI Data Grid
MUI Data Grid offers several features that make it ideal for building complex data tables:
- Virtualization: Renders only visible rows for performance optimization
- Pagination: Built-in support for both client and server-side pagination
- Sorting and Filtering: Multi-column sorting and customizable filtering
- Selection: Row selection with checkboxes or click events
- Column Management: Resizing, reordering, and hiding columns
- Theming: Full customization through MUI's theming system
- Accessibility: ARIA-compliant with keyboard navigation
Core Props and Configuration
Let's explore the essential props that make the Data Grid component so flexible:
Prop | Type | Description |
---|---|---|
rows | array | Array of data objects to display in the grid |
columns | array | Configuration for each column (field, header, width, etc.) |
pageSize | number | Number of rows per page |
rowsPerPageOptions | array | Available options for rows per page |
checkboxSelection | boolean | Enable row selection with checkboxes |
disableSelectionOnClick | boolean | Prevent row selection when clicking on a cell |
loading | boolean | Display loading state |
components | object | Override default components (toolbar, pagination, etc.) |
componentsProps | object | Props to pass to custom components |
onPageChange | function | Callback fired when page changes |
onPageSizeChange | function | Callback fired when page size changes |
Controlled vs. Uncontrolled Usage
The Data Grid can be used in both controlled and uncontrolled modes:
Uncontrolled mode is simpler - you provide the initial data and let the grid manage its internal state:
<DataGrid
rows={products}
columns={columns}
initialState={{
pagination: { pageSize: 5 },
sorting: { sortModel: [{ field: 'name', sort: 'asc' }] }
}}
autoHeight
/>
Controlled mode gives you full control over the grid's state, which is essential when integrating with state management solutions like Zustand:
<DataGrid
rows={products}
columns={columns}
pageSize={pageSize}
page={page}
sortModel={sortModel}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
onSortModelChange={handleSortModelChange}
autoHeight
/>
For our product table, we'll use controlled mode with Zustand to manage the grid's state.
Introduction to Zustand for State Management
Zustand is a lightweight state management library for React that provides a simple and intuitive API. It's an excellent alternative to more complex solutions like Redux, especially for managing component-specific state like our data grid.
Key Benefits of Zustand
- Minimal boilerplate: No providers, actions, or reducers needed
- TypeScript friendly: Great type inference out of the box
- Selective rendering: Components only re-render when their specific slice of state changes
- Middleware support: Includes middleware for persistence, immer, and more
- Small bundle size: ~1KB minified and gzipped
Basic Zustand Store Structure
A basic Zustand store looks like this:
import create from 'zustand';
const useStore = create((set) => ({
// State
count: 0,
// Actions
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 })
}));
For our data grid, we'll create a more sophisticated store to manage pagination, sorting, and product data.
Setting Up Your Project
Let's start building our paginated product table. First, we need to set up a new React project and install the necessary dependencies.
Creating a New React Project
If you don't have a project already, create one using Create React App or Vite:
# Using npm
npx create-react-app mui-datagrid-zustand
cd mui-datagrid-zustand
# Or using Vite
npm create vite@latest mui-datagrid-zustand -- --template react
cd mui-datagrid-zustand
Installing Dependencies
Now, let's install the required packages:
npm install @mui/material @mui/x-data-grid @emotion/react @emotion/styled zustand
This installs:
- MUI core components
- MUI Data Grid (community version)
- Emotion for styling
- Zustand for state management
Creating the Zustand Store for Product Data
Let's create a Zustand store to manage our product data and grid state. This will be the foundation of our application.
Setting Up the Store
Create a new file called productStore.js
in your project:
import { create } from 'zustand';
// Sample product data
const initialProducts = [
{ id: 1, name: 'Laptop', category: 'Electronics', price: 999, stock: 45 },
{ id: 2, name: 'Smartphone', category: 'Electronics', price: 699, stock: 120 },
{ id: 3, name: 'Headphones', category: 'Audio', price: 149, stock: 78 },
{ id: 4, name: 'Monitor', category: 'Electronics', price: 349, stock: 25 },
{ id: 5, name: 'Keyboard', category: 'Accessories', price: 59, stock: 95 },
{ id: 6, name: 'Mouse', category: 'Accessories', price: 29, stock: 105 },
{ id: 7, name: 'Tablet', category: 'Electronics', price: 399, stock: 35 },
{ id: 8, name: 'Speakers', category: 'Audio', price: 89, stock: 40 },
{ id: 9, name: 'External SSD', category: 'Storage', price: 129, stock: 60 },
{ id: 10, name: 'Webcam', category: 'Accessories', price: 49, stock: 30 },
{ id: 11, name: 'Printer', category: 'Office', price: 199, stock: 15 },
{ id: 12, name: 'Router', category: 'Networking', price: 79, stock: 25 },
{ id: 13, name: 'Smartwatch', category: 'Wearables', price: 249, stock: 50 },
{ id: 14, name: 'Camera', category: 'Photography', price: 599, stock: 20 },
{ id: 15, name: 'Gaming Console', category: 'Gaming', price: 499, stock: 10 },
];
// Create the store
const useProductStore = create((set) => ({
// Data state
products: initialProducts,
filteredProducts: initialProducts,
// Grid state
page: 0,
pageSize: 5,
sortModel: [{ field: 'name', sort: 'asc' }],
searchQuery: '',
// Actions
setPage: (page) => set({ page }),
setPageSize: (pageSize) => set({ pageSize }),
setSortModel: (sortModel) => set({ sortModel }),
// Search/filter functionality
setSearchQuery: (searchQuery) => set((state) => {
const filtered = state.products.filter(product =>
product.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
product.category.toLowerCase().includes(searchQuery.toLowerCase())
);
return {
searchQuery,
filteredProducts: searchQuery ? filtered : state.products,
// Reset to first page when searching
page: 0
};
}),
// Product CRUD operations
addProduct: (product) => set((state) => {
const newProduct = {
id: Math.max(0, ...state.products.map(p => p.id)) + 1,
...product
};
const updatedProducts = [...state.products, newProduct];
return {
products: updatedProducts,
filteredProducts: state.searchQuery ?
updatedProducts.filter(product =>
product.name.toLowerCase().includes(state.searchQuery.toLowerCase()) ||
product.category.toLowerCase().includes(state.searchQuery.toLowerCase())
) :
updatedProducts
};
}),
updateProduct: (id, updates) => set((state) => {
const updatedProducts = state.products.map(product =>
product.id === id ? { ...product, ...updates } : product
);
return {
products: updatedProducts,
filteredProducts: state.searchQuery ?
updatedProducts.filter(product =>
product.name.toLowerCase().includes(state.searchQuery.toLowerCase()) ||
product.category.toLowerCase().includes(state.searchQuery.toLowerCase())
) :
updatedProducts
};
}),
deleteProduct: (id) => set((state) => {
const updatedProducts = state.products.filter(product => product.id !== id);
return {
products: updatedProducts,
filteredProducts: state.searchQuery ?
updatedProducts.filter(product =>
product.name.toLowerCase().includes(state.searchQuery.toLowerCase()) ||
product.category.toLowerCase().includes(state.searchQuery.toLowerCase())
) :
updatedProducts
};
})
}));
export default useProductStore;
This store handles:
- Product data management (CRUD operations)
- Pagination state (current page, page size)
- Sorting state
- Search/filtering functionality
The store is designed to keep the grid's state in sync with the product data, making it easy to create a controlled Data Grid component.
Building the Product Table Component
Now, let's build our main component that will display the product table using MUI Data Grid and our Zustand store.
Creating the ProductTable Component
Create a new file called ProductTable.jsx
:
import React from 'react';
import {
DataGrid,
GridToolbar,
gridPageCountSelector,
gridPageSelector,
useGridApiContext,
useGridSelector
} from '@mui/x-data-grid';
import {
Box,
Typography,
TextField,
IconButton,
Chip,
Stack,
Pagination,
Paper
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import useProductStore from './productStore';
// Custom pagination component that uses MUI's Pagination
function CustomPagination() {
const apiRef = useGridApiContext();
const page = useGridSelector(apiRef, gridPageSelector);
const pageCount = useGridSelector(apiRef, gridPageCountSelector);
return (
<Pagination
color="primary"
count={pageCount}
page={page + 1}
onChange={(event, value) => apiRef.current.setPage(value - 1)}
/>
);
}
const ProductTable = () => {
// Get state and actions from our Zustand store
const {
filteredProducts,
page,
pageSize,
sortModel,
searchQuery,
setPage,
setPageSize,
setSortModel,
setSearchQuery,
deleteProduct
} = useProductStore();
// Define columns for the DataGrid
const columns = [
{
field: 'id',
headerName: 'ID',
width: 70
},
{
field: 'name',
headerName: 'Product Name',
flex: 1,
minWidth: 150
},
{
field: 'category',
headerName: 'Category',
width: 150,
renderCell: (params) => (
<Chip
label={params.value}
color="primary"
variant="outlined"
size="small"
/>
)
},
{
field: 'price',
headerName: 'Price',
width: 120,
type: 'number',
valueFormatter: (params) => {
return `$${params.value.toFixed(2)}`;
}
},
{
field: 'stock',
headerName: 'Stock',
width: 120,
type: 'number',
renderCell: (params) => (
<Chip
label={params.value}
color={params.value > 50 ? "success" : params.value > 20 ? "warning" : "error"}
size="small"
/>
)
},
{
field: 'actions',
headerName: 'Actions',
width: 120,
sortable: false,
renderCell: (params) => (
<Stack direction="row" spacing={1}>
<IconButton size="small" color="primary" aria-label="edit">
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="error"
aria-label="delete"
onClick={() => deleteProduct(params.row.id)}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Stack>
)
}
];
// Handle search input change
const handleSearchChange = (event) => {
setSearchQuery(event.target.value);
};
return (
<Paper elevation={3} sx={{ p: 3, maxWidth: 1200, mx: 'auto', my: 4 }}>
<Typography variant="h4" gutterBottom>
Product Inventory
</Typography>
<Box sx={{ mb: 2 }}>
<TextField
label="Search products"
variant="outlined"
size="small"
fullWidth
value={searchQuery}
onChange={handleSearchChange}
placeholder="Search by name or category..."
sx={{ mb: 2 }}
/>
</Box>
<Box sx={{ height: 400, width: '100%' }}>
<DataGrid
rows={filteredProducts}
columns={columns}
pagination
page={page}
pageSize={pageSize}
rowsPerPageOptions={[5, 10, 25]}
rowCount={filteredProducts.length}
onPageChange={(newPage) => setPage(newPage)}
onPageSizeChange={(newPageSize) => setPageSize(newPageSize)}
paginationMode="client"
sortingMode="client"
sortModel={sortModel}
onSortModelChange={(newSortModel) => setSortModel(newSortModel)}
components={{
Toolbar: GridToolbar,
Pagination: CustomPagination,
}}
componentsProps={{
toolbar: {
showQuickFilter: true,
quickFilterProps: { debounceMs: 500 },
},
}}
disableSelectionOnClick
disableDensitySelector
sx={{
'& .MuiDataGrid-cell:hover': {
color: 'primary.main',
},
'& .MuiDataGrid-columnHeader': {
backgroundColor: 'primary.light',
color: 'primary.contrastText',
},
}}
/>
</Box>
</Paper>
);
};
export default ProductTable;
In this component, we:
- Use our Zustand store to manage the data grid state
- Define columns with custom cell renderers for categories and stock levels
- Implement a search field that filters products
- Create a custom pagination component
- Add styling to enhance the visual appeal of the grid
Integrating the Component in Your App
Now, let's update the App.jsx
file to use our ProductTable
component:
import React from 'react';
import { CssBaseline, ThemeProvider, createTheme, Container, Box } from '@mui/material';
import ProductTable from './ProductTable';
// Create a custom theme
const theme = createTheme({
palette: {
primary: {
main: '#1976d2',
},
secondary: {
main: '#dc004e',
},
},
});
function App() {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Container maxWidth="lg">
<Box sx={{ my: 4 }}>
<ProductTable />
</Box>
</Container>
</ThemeProvider>
);
}
export default App;
Enhancing the Product Table with Advanced Features
Now that we have a basic product table working, let's enhance it with more advanced features.
Adding Product Form for Create/Edit Operations
Let's create a form to add or edit products:
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Button,
MenuItem,
Grid,
InputAdornment
} from '@mui/material';
import useProductStore from './productStore';
const categories = [
'Electronics',
'Audio',
'Accessories',
'Storage',
'Office',
'Networking',
'Wearables',
'Photography',
'Gaming'
];
const ProductForm = ({ open, handleClose, editProduct = null }) => {
const { addProduct, updateProduct } = useProductStore();
// Default empty form state
const defaultFormState = {
name: '',
category: '',
price: '',
stock: ''
};
const [formData, setFormData] = useState(defaultFormState);
const [errors, setErrors] = useState({});
// If we're editing, populate the form with product data
useEffect(() => {
if (editProduct) {
setFormData({
name: editProduct.name,
category: editProduct.category,
price: editProduct.price.toString(),
stock: editProduct.stock.toString()
});
} else {
setFormData(defaultFormState);
}
setErrors({});
}, [editProduct, open]);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData({ ...formData, [name]: value });
// Clear error when user types
if (errors[name]) {
setErrors({ ...errors, [name]: null });
}
};
const validate = () => {
const newErrors = {};
if (!formData.name.trim()) newErrors.name = 'Name is required';
if (!formData.category) newErrors.category = 'Category is required';
if (!formData.price) {
newErrors.price = 'Price is required';
} else if (isNaN(Number(formData.price)) || Number(formData.price) <= 0) {
newErrors.price = 'Price must be a positive number';
}
if (!formData.stock) {
newErrors.stock = 'Stock is required';
} else if (isNaN(Number(formData.stock)) || !Number.isInteger(Number(formData.stock)) || Number(formData.stock) < 0) {
newErrors.stock = 'Stock must be a non-negative integer';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = () => {
if (!validate()) return;
const productData = {
name: formData.name.trim(),
category: formData.category,
price: Number(formData.price),
stock: parseInt(formData.stock, 10)
};
if (editProduct) {
updateProduct(editProduct.id, productData);
} else {
addProduct(productData);
}
handleClose();
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
<DialogTitle>
{editProduct ? 'Edit Product' : 'Add New Product'}
</DialogTitle>
<DialogContent>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid item xs={12}>
<TextField
name="name"
label="Product Name"
fullWidth
value={formData.name}
onChange={handleChange}
error={!!errors.name}
helperText={errors.name}
autoFocus
/>
</Grid>
<Grid item xs={12}>
<TextField
name="category"
label="Category"
select
fullWidth
value={formData.category}
onChange={handleChange}
error={!!errors.category}
helperText={errors.category}
>
{categories.map(category => (
<MenuItem key={category} value={category}>
{category}
</MenuItem>
))}
</TextField>
</Grid>
<Grid item xs={6}>
<TextField
name="price"
label="Price"
fullWidth
value={formData.price}
onChange={handleChange}
error={!!errors.price}
helperText={errors.price}
InputProps={{
startAdornment: <InputAdornment position="start">$</InputAdornment>,
}}
/>
</Grid>
<Grid item xs={6}>
<TextField
name="stock"
label="Stock"
fullWidth
value={formData.stock}
onChange={handleChange}
error={!!errors.stock}
helperText={errors.stock}
type="number"
/>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} color="primary">
Cancel
</Button>
<Button onClick={handleSubmit} color="primary" variant="contained">
{editProduct ? 'Update' : 'Add'} Product
</Button>
</DialogActions>
</Dialog>
);
};
export default ProductForm;
Updating the ProductTable Component
Now, let's update our ProductTable
component to include the form for adding/editing products:
import React, { useState } from 'react';
import {
DataGrid,
GridToolbar,
gridPageCountSelector,
gridPageSelector,
useGridApiContext,
useGridSelector
} from '@mui/x-data-grid';
import {
Box,
Typography,
TextField,
IconButton,
Chip,
Stack,
Pagination,
Paper,
Button,
Alert,
Snackbar
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import useProductStore from './productStore';
import ProductForm from './ProductForm';
// Custom pagination component that uses MUI's Pagination
function CustomPagination() {
const apiRef = useGridApiContext();
const page = useGridSelector(apiRef, gridPageSelector);
const pageCount = useGridSelector(apiRef, gridPageCountSelector);
return (
<Pagination
color="primary"
count={pageCount}
page={page + 1}
onChange={(event, value) => apiRef.current.setPage(value - 1)}
/>
);
}
const ProductTable = () => {
// Get state and actions from our Zustand store
const {
filteredProducts,
page,
pageSize,
sortModel,
searchQuery,
setPage,
setPageSize,
setSortModel,
setSearchQuery,
deleteProduct
} = useProductStore();
// Local state for form dialog and snackbar
const [formOpen, setFormOpen] = useState(false);
const [editProduct, setEditProduct] = useState(null);
const [snackbar, setSnackbar] = useState({
open: false,
message: '',
severity: 'success'
});
// Handle opening the form for editing
const handleEdit = (product) => {
setEditProduct(product);
setFormOpen(true);
};
// Handle opening the form for adding
const handleAdd = () => {
setEditProduct(null);
setFormOpen(true);
};
// Handle closing the form
const handleFormClose = () => {
setFormOpen(false);
setEditProduct(null);
};
// Handle deleting a product with confirmation
const handleDelete = (id) => {
if (window.confirm('Are you sure you want to delete this product?')) {
deleteProduct(id);
setSnackbar({
open: true,
message: 'Product deleted successfully',
severity: 'success'
});
}
};
// Close snackbar
const handleSnackbarClose = () => {
setSnackbar({ ...snackbar, open: false });
};
// Define columns for the DataGrid
const columns = [
{
field: 'id',
headerName: 'ID',
width: 70
},
{
field: 'name',
headerName: 'Product Name',
flex: 1,
minWidth: 150
},
{
field: 'category',
headerName: 'Category',
width: 150,
renderCell: (params) => (
<Chip
label={params.value}
color="primary"
variant="outlined"
size="small"
/>
)
},
{
field: 'price',
headerName: 'Price',
width: 120,
type: 'number',
valueFormatter: (params) => {
return `$${params.value.toFixed(2)}`;
}
},
{
field: 'stock',
headerName: 'Stock',
width: 120,
type: 'number',
renderCell: (params) => (
<Chip
label={params.value}
color={params.value > 50 ? "success" : params.value > 20 ? "warning" : "error"}
size="small"
/>
)
},
{
field: 'actions',
headerName: 'Actions',
width: 120,
sortable: false,
renderCell: (params) => (
<Stack direction="row" spacing={1}>
<IconButton
size="small"
color="primary"
aria-label="edit"
onClick={() => handleEdit(params.row)}
>
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="error"
aria-label="delete"
onClick={() => handleDelete(params.row.id)}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Stack>
)
}
];
// Handle search input change
const handleSearchChange = (event) => {
setSearchQuery(event.target.value);
};
return (
<Paper elevation={3} sx={{ p: 3, maxWidth: 1200, mx: 'auto', my: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h4">
Product Inventory
</Typography>
<Button
variant="contained"
color="primary"
startIcon={<AddIcon />}
onClick={handleAdd}
>
Add Product
</Button>
</Box>
<Box sx={{ mb: 2 }}>
<TextField
label="Search products"
variant="outlined"
size="small"
fullWidth
value={searchQuery}
onChange={handleSearchChange}
placeholder="Search by name or category..."
sx={{ mb: 2 }}
/>
</Box>
<Box sx={{ height: 400, width: '100%' }}>
<DataGrid
rows={filteredProducts}
columns={columns}
pagination
page={page}
pageSize={pageSize}
rowsPerPageOptions={[5, 10, 25]}
rowCount={filteredProducts.length}
onPageChange={(newPage) => setPage(newPage)}
onPageSizeChange={(newPageSize) => setPageSize(newPageSize)}
paginationMode="client"
sortingMode="client"
sortModel={sortModel}
onSortModelChange={(newSortModel) => setSortModel(newSortModel)}
components={{
Toolbar: GridToolbar,
Pagination: CustomPagination,
}}
componentsProps={{
toolbar: {
showQuickFilter: true,
quickFilterProps: { debounceMs: 500 },
},
}}
disableSelectionOnClick
disableDensitySelector
sx={{
'& .MuiDataGrid-cell:hover': {
color: 'primary.main',
},
'& .MuiDataGrid-columnHeader': {
backgroundColor: 'primary.light',
color: 'primary.contrastText',
},
}}
/>
</Box>
{/* Product Form Dialog */}
<ProductForm
open={formOpen}
handleClose={handleFormClose}
editProduct={editProduct}
/>
{/* Snackbar for notifications */}
<Snackbar
open={snackbar.open}
autoHideDuration={4000}
onClose={handleSnackbarClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<Alert
onClose={handleSnackbarClose}
severity={snackbar.severity}
variant="filled"
>
{snackbar.message}
</Alert>
</Snackbar>
</Paper>
);
};
export default ProductTable;
Advanced DataGrid Customization
Let's explore some advanced customization options for the MUI DataGrid to make our product table even more powerful.
Custom Toolbar with Export Options
We can create a custom toolbar that extends the built-in GridToolbar with additional functionality:
import React from 'react';
import {
GridToolbarContainer,
GridToolbarColumnsButton,
GridToolbarFilterButton,
GridToolbarDensitySelector,
GridToolbarExport
} from '@mui/x-data-grid';
import { Button, Divider, Stack } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import RefreshIcon from '@mui/icons-material/Refresh';
const CustomToolbar = ({ onAdd, onRefresh }) => {
return (
<GridToolbarContainer>
<Stack
direction="row"
divider={<Divider orientation="vertical" flexItem />}
spacing={1}
sx={{ alignItems: 'center', width: '100%', p: 1 }}
>
<GridToolbarColumnsButton />
<GridToolbarFilterButton />
<GridToolbarDensitySelector />
<GridToolbarExport
printOptions={{ disableToolbarButton: true }}
csvOptions={{
fileName: 'product-inventory',
delimiter: ',',
utf8WithBom: true,
}}
/>
<div style={{ flexGrow: 1 }} />
<Button
color="primary"
startIcon={<RefreshIcon />}
onClick={onRefresh}
size="small"
>
Refresh
</Button>
<Button
variant="contained"
color="primary"
startIcon={<AddIcon />}
onClick={onAdd}
size="small"
>
Add Product
</Button>
</Stack>
</GridToolbarContainer>
);
};
export default CustomToolbar;
Then update the ProductTable
component to use this custom toolbar:
// In ProductTable.jsx
// Import the custom toolbar
import CustomToolbar from './CustomToolbar';
// ...existing code...
// Inside the ProductTable component
const refreshData = () => {
// You could reload data from an API here
// For our example, we'll just reset the search
setSearchQuery('');
};
// Update the components prop in DataGrid
components={{
Toolbar: (props) => (
<CustomToolbar
{...props}
onAdd={handleAdd}
onRefresh={refreshData}
/>
),
Pagination: CustomPagination,
}}
// Remove componentsProps.toolbar since we're handling it in our custom component
Custom Cell Renderers for Better Visualization
Let's enhance our product table with more advanced cell renderers:
// In ProductTable.jsx
// Add these imports
import { LinearProgress, Tooltip } from '@mui/material';
import TrendingUpIcon from '@mui/icons-material/TrendingUp';
import TrendingDownIcon from '@mui/icons-material/TrendingDown';
// Then update the columns definition
const columns = [
// ... existing columns ...
{
field: 'price',
headerName: 'Price',
width: 120,
type: 'number',
renderCell: (params) => (
<Tooltip title={`${params.value.toFixed(2)} USD`}>
<Typography variant="body2" sx={{ fontWeight: 'medium' }}>
${params.value.toFixed(2)}
</Typography>
</Tooltip>
)
},
{
field: 'stock',
headerName: 'Stock',
width: 150,
type: 'number',
renderCell: (params) => {
const value = params.value;
const stockPercent = Math.min(100, (value / 100) * 100);
let color = 'error';
if (value > 50) color = 'success';
else if (value > 20) color = 'warning';
return (
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<Box sx={{ width: '60%', mr: 1 }}>
<LinearProgress
variant="determinate"
value={stockPercent}
color={color}
sx={{ height: 8, borderRadius: 5 }}
/>
</Box>
<Box sx={{ minWidth: 35 }}>
<Typography variant="body2" color="text.secondary">
{value}
</Typography>
</Box>
</Box>
);
}
},
// Add a new column for trend (this would come from real data in a real app)
{
field: 'trend',
headerName: 'Trend',
width: 120,
sortable: false,
renderCell: (params) => {
// Simulate trend data (would come from real data in a production app)
const trend = params.row.id % 3 === 0 ? 'up' : 'down';
const percent = Math.floor(Math.random() * 10) + 1;
return (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{trend === 'up' ? (
<>
<TrendingUpIcon color="success" fontSize="small" />
<Typography variant="body2" color="success.main" sx={{ ml: 0.5 }}>
+{percent}%
</Typography>
</>
) : (
<>
<TrendingDownIcon color="error" fontSize="small" />
<Typography variant="body2" color="error.main" sx={{ ml: 0.5 }}>
-{percent}%
</Typography>
</>
)}
</Box>
);
}
},
// ... existing actions column ...
];
Adding Row Selection with Bulk Actions
Let's add row selection capability with bulk actions:
// In ProductTable.jsx
// Add state for selection
const [selectionModel, setSelectionModel] = useState([]);
// Add a function to handle bulk delete
const handleBulkDelete = () => {
if (selectionModel.length === 0) return;
if (window.confirm(`Are you sure you want to delete ${selectionModel.length} selected products?`)) {
// Delete each selected product
selectionModel.forEach(id => deleteProduct(id));
setSnackbar({
open: true,
message: `${selectionModel.length} products deleted successfully`,
severity: 'success'
});
// Clear selection
setSelectionModel([]);
}
};
// Update the DataGrid component
<DataGrid
// ...existing props...
checkboxSelection
selectionModel={selectionModel}
onSelectionModelChange={(newSelectionModel) => {
setSelectionModel(newSelectionModel);
}}
// ...other existing props...
/>
// Add a bulk actions component above the grid when items are selected
{selectionModel.length > 0 && (
<Box sx={{
p: 2,
mb: 2,
backgroundColor: 'primary.light',
borderRadius: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<Typography variant="body1">
{selectionModel.length} {selectionModel.length === 1 ? 'product' : 'products'} selected
</Typography>
<Button
variant="contained"
color="error"
startIcon={<DeleteIcon />}
onClick={handleBulkDelete}
size="small"
>
Delete Selected
</Button>
</Box>
)}
Performance Optimization
When working with large datasets in MUI DataGrid, performance optimization becomes crucial. Let's explore some techniques to ensure our product table remains responsive.
Using Virtualization
MUI DataGrid already implements virtualization by default, meaning it only renders the rows that are visible in the viewport. This is a key feature for handling large datasets efficiently.
Optimizing Zustand Store
For large datasets, we can optimize our Zustand store by using selectors to prevent unnecessary re-renders:
// Update how we consume the store in ProductTable.jsx
const filteredProducts = useProductStore(state => state.filteredProducts);
const page = useProductStore(state => state.page);
const pageSize = useProductStore(state => state.pageSize);
const sortModel = useProductStore(state => state.sortModel);
const searchQuery = useProductStore(state => state.searchQuery);
// Get actions separately
const {
setPage,
setPageSize,
setSortModel,
setSearchQuery,
deleteProduct
} = useProductStore();
This approach ensures that the component only re-renders when the specific pieces of state it depends on change.
Debouncing Search Input
To prevent excessive filtering when typing in the search field, let's implement debouncing:
import React, { useState, useEffect, useCallback } from 'react';
// Other imports...
import { debounce } from 'lodash'; // You'll need to install lodash
// Inside the ProductTable component
const [searchInput, setSearchInput] = useState('');
// Create a debounced search function
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedSearch = useCallback(
debounce((value) => {
setSearchQuery(value);
}, 300),
[setSearchQuery]
);
// Handle search input change
const handleSearchChange = (event) => {
const value = event.target.value;
setSearchInput(value);
debouncedSearch(value);
};
// Update the TextField to use searchInput instead of searchQuery
<TextField
label="Search products"
variant="outlined"
size="small"
fullWidth
value={searchInput}
onChange={handleSearchChange}
placeholder="Search by name or category..."
sx={{ mb: 2 }}
/>
Memoizing Expensive Components
For complex cell renderers, we can use React.memo to prevent unnecessary re-renders:
// Create a memoized stock cell renderer
const StockCellRenderer = React.memo(({ value }) => {
const stockPercent = Math.min(100, (value / 100) * 100);
let color = 'error';
if (value > 50) color = 'success';
else if (value > 20) color = 'warning';
return (
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<Box sx={{ width: '60%', mr: 1 }}>
<LinearProgress
variant="determinate"
value={stockPercent}
color={color}
sx={{ height: 8, borderRadius: 5 }}
/>
</Box>
<Box sx={{ minWidth: 35 }}>
<Typography variant="body2" color="text.secondary">
{value}
</Typography>
</Box>
</Box>
);
});
// Then use it in the column definition
{
field: 'stock',
headerName: 'Stock',
width: 150,
type: 'number',
renderCell: (params) => <StockCellRenderer value={params.value} />
}
Best Practices and Common Issues
Let's cover some best practices and common issues you might encounter when working with MUI DataGrid and Zustand.
Best Practices
-
Separate Concerns: Keep your data management (Zustand store) separate from your UI components for better maintainability.
-
Use Controlled Mode: For complex data grids, always use controlled mode to have full control over the grid's state.
-
Optimize Rendering: Use memoization and debouncing to prevent excessive re-renders, especially for complex cell renderers.
-
Handle Loading States: Always show loading indicators when fetching data to improve user experience:
// Add loading state to your store
const isLoading = useProductStore(state => state.isLoading);
// Then use it in your DataGrid
<DataGrid
// ...other props
loading={isLoading}
// ...other props
/>
- Error Handling: Implement proper error handling for data operations:
// In your store
const error = useProductStore(state => state.error);
// In your component
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
Common Issues and Solutions
-
Issue: DataGrid re-renders too often, causing performance issues. Solution: Use selectors with Zustand and memoize components.
-
Issue: Column widths don't adjust properly. Solution: Use a combination of
width
,minWidth
, andflex
properties:
{
field: 'name',
headerName: 'Product Name',
flex: 1,
minWidth: 150 // Ensures column doesn't get too narrow
}
- Issue: Custom cell renderers don't align properly. Solution: Use Box or Stack components with proper alignment:
renderCell: (params) => (
<Box sx={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start'
}}>
{/* Cell content */}
</Box>
)
- Issue: Pagination doesn't update when data changes. Solution: Reset page to 0 when data filtering changes:
// In your store's setSearchQuery action
setSearchQuery: (searchQuery) => set((state) => {
const filtered = state.products.filter(/* filtering logic */);
return {
searchQuery,
filteredProducts: searchQuery ? filtered : state.products,
page: 0 // Reset to first page when filtering
};
})
- Issue: Sorting doesn't work with custom cell renderers.
Solution: Ensure the
valueGetter
is defined for columns with custom renderers:
{
field: 'price',
headerName: 'Price',
type: 'number',
valueGetter: (params) => params.row.price, // Ensures sorting works
renderCell: (params) => (
<Typography>${params.value.toFixed(2)}</Typography>
)
}
Advanced Integration with Server-Side Data
For real-world applications, you'll often need to fetch data from an API. Let's enhance our product table to work with server-side data.
Updating the Zustand Store for API Integration
import { create } from 'zustand';
const useProductStore = create((set, get) => ({
// Data state
products: [],
filteredProducts: [],
totalCount: 0,
// Grid state
page: 0,
pageSize: 5,
sortModel: [{ field: 'name', sort: 'asc' }],
searchQuery: '',
// Loading and error states
isLoading: false,
error: null,
// Fetch products from API
fetchProducts: async () => {
try {
set({ isLoading: true, error: null });
// Example API call - replace with your actual API
const response = await fetch('https://api.example.com/products');
if (!response.ok) {
throw new Error('Failed to fetch products');
}
const data = await response.json();
set({
products: data.products,
filteredProducts: data.products,
totalCount: data.total,
isLoading: false
});
} catch (error) {
set({ error: error.message, isLoading: false });
console.error('Error fetching products:', error);
}
},
// Server-side pagination, sorting, and filtering
fetchProductsWithParams: async () => {
try {
const { page, pageSize, sortModel, searchQuery } = get();
set({ isLoading: true, error: null });
// Build query parameters
const params = new URLSearchParams({
page: page + 1, // APIs often use 1-based indexing
pageSize,
sortField: sortModel.length > 0 ? sortModel[0].field : 'name',
sortDirection: sortModel.length > 0 ? sortModel[0].sort : 'asc',
search: searchQuery
});
// Example API call with query parameters
const response = await fetch(`https://api.example.com/products?${params.toString()}`);
if (!response.ok) {
throw new Error('Failed to fetch products');
}
const data = await response.json();
set({
products: data.products,
filteredProducts: data.products,
totalCount: data.total,
isLoading: false
});
} catch (error) {
set({ error: error.message, isLoading: false });
console.error('Error fetching products:', error);
}
},
// Pagination and sorting actions that trigger API calls
setPage: (page) => {
set({ page });
get().fetchProductsWithParams();
},
setPageSize: (pageSize) => {
set({ pageSize, page: 0 }); // Reset to first page
get().fetchProductsWithParams();
},
setSortModel: (sortModel) => {
set({ sortModel });
get().fetchProductsWithParams();
},
setSearchQuery: (searchQuery) => {
set({ searchQuery, page: 0 }); // Reset to first page
get().fetchProductsWithParams();
},
// CRUD operations
addProduct: async (product) => {
try {
set({ isLoading: true, error: null });
// Example API call to add a product
const response = await fetch('https://api.example.com/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(product)
});
if (!response.ok) {
throw new Error('Failed to add product');
}
// Refresh the product list
get().fetchProductsWithParams();
} catch (error) {
set({ error: error.message, isLoading: false });
console.error('Error adding product:', error);
}
},
updateProduct: async (id, updates) => {
try {
set({ isLoading: true, error: null });
// Example API call to update a product
const response = await fetch(`https://api.example.com/products/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
if (!response.ok) {
throw new Error('Failed to update product');
}
// Refresh the product list
get().fetchProductsWithParams();
} catch (error) {
set({ error: error.message, isLoading: false });
console.error('Error updating product:', error);
}
},
deleteProduct: async (id) => {
try {
set({ isLoading: true, error: null });
// Example API call to delete a product
const response = await fetch(`https://api.example.com/products/${id}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete product');
}
// Refresh the product list
get().fetchProductsWithParams();
} catch (error) {
set({ error: error.message, isLoading: false });
console.error('Error deleting product:', error);
}
}
}));
export default useProductStore;
Updating the ProductTable Component for Server-Side Integration
// In ProductTable.jsx
import React, { useState, useEffect } from 'react';
// Other imports...
const ProductTable = () => {
// Get state and actions from our Zustand store
const {
filteredProducts,
totalCount,
page,
pageSize,
sortModel,
searchQuery,
isLoading,
error,
fetchProductsWithParams,
setPage,
setPageSize,
setSortModel,
setSearchQuery,
deleteProduct
} = useProductStore();
// Local state for form dialog and snackbar
const [formOpen, setFormOpen] = useState(false);
const [editProduct, setEditProduct] = useState(null);
const [snackbar, setSnackbar] = useState({
open: false,
message: '',
severity: 'success'
});
// Fetch products on component mount
useEffect(() => {
fetchProductsWithParams();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Rest of the component remains similar, but update the DataGrid:
<DataGrid
rows={filteredProducts}
columns={columns}
pagination
page={page}
pageSize={pageSize}
rowsPerPageOptions={[5, 10, 25]}
rowCount={totalCount} // Use total count from server
paginationMode="server" // Set to server for server-side pagination
sortingMode="server" // Set to server for server-side sorting
onPageChange={(newPage) => setPage(newPage)}
onPageSizeChange={(newPageSize) => setPageSize(newPageSize)}
sortModel={sortModel}
onSortModelChange={(newSortModel) => setSortModel(newSortModel)}
loading={isLoading}
// ...other props
/>
// Add error handling
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
)}
// Rest of the component...
}
Wrapping Up
In this comprehensive guide, we've built a powerful product table using MUI Data Grid with Zustand for state management. We've covered everything from basic setup to advanced features and optimizations.
The combination of MUI Data Grid and Zustand provides an excellent solution for building complex data tables in React applications. The Data Grid offers rich features like pagination, sorting, and filtering, while Zustand provides a simple yet powerful state management solution.
By following the patterns and practices outlined in this guide, you can build robust, performant, and maintainable data-rich applications. Whether you're working with client-side or server-side data, the techniques we've explored will help you create a polished user experience.
Remember to consider performance optimizations when working with large datasets, and always design your state management with scalability in mind. With these tools and techniques, you're well-equipped to build sophisticated data tables for your React applications.