Menu

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:

  1. Virtualization: Renders only visible rows for performance optimization
  2. Pagination: Built-in support for both client and server-side pagination
  3. Sorting and Filtering: Multi-column sorting and customizable filtering
  4. Selection: Row selection with checkboxes or click events
  5. Column Management: Resizing, reordering, and hiding columns
  6. Theming: Full customization through MUI's theming system
  7. Accessibility: ARIA-compliant with keyboard navigation

Core Props and Configuration

Let's explore the essential props that make the Data Grid component so flexible:

PropTypeDescription
rowsarrayArray of data objects to display in the grid
columnsarrayConfiguration for each column (field, header, width, etc.)
pageSizenumberNumber of rows per page
rowsPerPageOptionsarrayAvailable options for rows per page
checkboxSelectionbooleanEnable row selection with checkboxes
disableSelectionOnClickbooleanPrevent row selection when clicking on a cell
loadingbooleanDisplay loading state
componentsobjectOverride default components (toolbar, pagination, etc.)
componentsPropsobjectProps to pass to custom components
onPageChangefunctionCallback fired when page changes
onPageSizeChangefunctionCallback 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

  1. Minimal boilerplate: No providers, actions, or reducers needed
  2. TypeScript friendly: Great type inference out of the box
  3. Selective rendering: Components only re-render when their specific slice of state changes
  4. Middleware support: Includes middleware for persistence, immer, and more
  5. 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:

  1. Use our Zustand store to manage the data grid state
  2. Define columns with custom cell renderers for categories and stock levels
  3. Implement a search field that filters products
  4. Create a custom pagination component
  5. 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

  1. Separate Concerns: Keep your data management (Zustand store) separate from your UI components for better maintainability.

  2. Use Controlled Mode: For complex data grids, always use controlled mode to have full control over the grid's state.

  3. Optimize Rendering: Use memoization and debouncing to prevent excessive re-renders, especially for complex cell renderers.

  4. 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
/>
  1. 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

  1. Issue: DataGrid re-renders too often, causing performance issues. Solution: Use selectors with Zustand and memoize components.

  2. Issue: Column widths don't adjust properly. Solution: Use a combination of width, minWidth, and flex properties:

{
  field: 'name',
  headerName: 'Product Name',
  flex: 1,
  minWidth: 150 // Ensures column doesn't get too narrow
}
  1. 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>
)
  1. 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
  };
})
  1. 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.