Menu

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:

  1. Table - The root component that wraps all table elements
  2. TableHead - Container for header row(s)
  3. TableBody - Container for data rows
  4. TableRow - Represents a table row
  5. TableCell - Represents a table cell
  6. TablePagination - Adds pagination controls to the table
  7. TableSortLabel - 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:

PropTypeDefaultDescription
childrennode-Table contents, usually TableHead and TableBody
componentelementType'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
stickyHeaderboolfalseIf true, the TableHead will remain fixed at the top
sxobject-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:

  1. Using the sx prop: The most direct way to apply styles to MUI components.
  2. Theme customization: Applying global styles through theme customization.
  3. Styled components: Using MUI's styled API to create custom styled table components.
  4. 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:

  1. Use <TableHead> for header rows to ensure proper semantic structure
  2. Include aria-sort attributes on sortable columns (handled by TableSortLabel)
  3. Ensure proper color contrast for text and background
  4. 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:

  1. The current table data
  2. Sorting state (column and direction)
  3. 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:

  1. Simplicity: Zustand has a minimal API that's easy to learn and use
  2. Performance: It's optimized for performance with minimal re-renders
  3. No boilerplate: Unlike Redux, Zustand requires minimal setup code
  4. Hooks-based: Works naturally with React's hooks system
  5. 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:

  1. State:

    • data and filteredData hold our table data
    • orderBy and orderDirection track sorting state
    • page and rowsPerPage manage pagination
  2. Actions:

    • setOrder handles sorting logic
    • setPage and setRowsPerPage handle pagination
    • filterData provides filtering capability (optional)
  3. Helper Functions:

    • createInitialData generates sample data
    • sortData 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:

  1. Pulls state and actions from our Zustand store
  2. Renders a table with sortable columns using TableSortLabel
  3. Implements pagination with TablePagination
  4. 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.


// 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:

  1. A search input field with an icon
  2. Local state to track the search term
  3. A handler to update the search term and filter the data
  4. 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:

  1. Loading state with a CircularProgress indicator
  2. Error state with an Alert component
  3. 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:

  1. Checkboxes for row selection
  2. A toolbar that changes based on selection state
  3. Visual indicators for selected rows
  4. 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

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

  1. 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
  });
}
  1. 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]);
  1. 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

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

  1. 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
  };
})
  1. 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.