Menu

Building a Task List with MUI Checkbox and Zustand: A Complete Guide

As front-end developers, we often need to create interactive lists where users can select multiple items to perform actions on them. Material UI's Checkbox component combined with Zustand for state management provides a powerful and efficient solution for building such interfaces.

In this guide, I'll walk you through creating a fully functional task list with selectable items using React, MUI, and Zustand. By the end, you'll have a reusable, performant component that handles complex selection states while maintaining a clean codebase.

Learning Objectives

After completing this tutorial, you'll be able to:

  • Implement MUI Checkbox components in various configurations
  • Create controlled checkbox groups with intermediate states
  • Build a task management system with single and bulk selection
  • Manage complex UI state efficiently with Zustand
  • Apply accessibility best practices to checkbox interactions
  • Optimize performance with proper state management patterns

Understanding MUI Checkbox Component

Before diving into our implementation, let's explore the MUI Checkbox component in detail to understand its capabilities and how we can leverage them effectively.

Core Features and Props

The Checkbox component from MUI is a versatile input that allows users to select one or multiple items from a set. It supports various states including checked, unchecked, and indeterminate (partially checked), making it perfect for our task list application.

PropTypeDefaultDescription
checkedboolean-If true, the component is checked (controlled component)
defaultCheckedbooleanfalseThe default checked state (uncontrolled component)
disabledbooleanfalseIf true, the component is disabled
indeterminatebooleanfalseIf true, the component appears indeterminate
onChangefunction-Callback fired when the state changes
colorstring'primary'The color of the component ('primary', 'secondary', 'error', 'info', 'success', 'warning', or custom)
sizestring'medium'The size of the component ('small', 'medium', 'large')
iconnode-The icon to display when the component is unchecked
checkedIconnode-The icon to display when the component is checked
requiredbooleanfalseIf true, the checkbox will be required

Controlled vs Uncontrolled Usage

When working with MUI Checkbox, we can use it in either controlled or uncontrolled mode:

Controlled Mode: You explicitly manage the checked state through React state and handle changes via the onChange prop. This gives you full control over the component's behavior and is generally recommended for complex applications.


import { useState } from 'react';
import { Checkbox } from '@mui/material';

function ControlledCheckbox() {
  const [checked, setChecked] = useState(false);
  
  const handleChange = (event) => {
    setChecked(event.target.checked);
  };
  
  return (
    <Checkbox
      checked={checked}
      onChange={handleChange}
    />
  );
}

Uncontrolled Mode: The checkbox manages its own state internally using the DOM. You can set an initial value with defaultChecked but don't control it afterward. This approach is simpler but offers less control.


import { Checkbox } from '@mui/material';

function UncontrolledCheckbox() {
  return <Checkbox defaultChecked />;
}

For our task list application, we'll use the controlled approach since we need to coordinate the state of multiple checkboxes and implement features like "select all."

Checkbox Variants and Customization

MUI Checkbox supports various visual configurations through props and theming. Here are some common customizations:

Size Variants:


<Checkbox size="small" />
<Checkbox size="medium" /> {/* default */}
<Checkbox size="large" />

Color Variants:


<Checkbox color="primary" /> {/* default */}
<Checkbox color="secondary" />
<Checkbox color="error" />
<Checkbox color="warning" />
<Checkbox color="info" />
<Checkbox color="success" />

Custom Icons:


import { Checkbox } from '@mui/material';
import FavoriteBorder from '@mui/icons-material/FavoriteBorder';
import Favorite from '@mui/icons-material/Favorite';

function CustomIconCheckbox() {
  return (
    <Checkbox
      icon={<FavoriteBorder />}
      checkedIcon={<Favorite />}
    />
  );
}

Styling with sx Prop:


<Checkbox 
  sx={{
    '&.Mui-checked': {
      color: '#ff5722',
    },
    '&:hover': {
      backgroundColor: 'rgba(255, 87, 34, 0.1)',
    },
  }}
/>

Intermediate State for Parent-Child Relationships

One of the most powerful features of MUI Checkbox is the indeterminate prop, which creates a third "partially checked" state. This is especially useful for implementing a "select all" checkbox that controls a group of child checkboxes.


import { useState } from 'react';
import { Checkbox, FormControlLabel, FormGroup } from '@mui/material';

function IndeterminateCheckboxExample() {
  const [parent, setParent] = useState(false);
  const [children, setChildren] = useState({
    child1: false,
    child2: false,
    child3: false,
  });
  
  const handleParentChange = (event) => {
    const newChecked = event.target.checked;
    setParent(newChecked);
    
    const newChildren = {};
    Object.keys(children).forEach(key => {
      newChildren[key] = newChecked;
    });
    setChildren(newChildren);
  };
  
  const handleChildChange = (event) => {
    const { name, checked } = event.target;
    setChildren({
      ...children,
      [name]: checked,
    });
  };
  
  // Calculate if parent should be indeterminate
  const childValues = Object.values(children);
  const isIndeterminate = childValues.some(Boolean) && !childValues.every(Boolean);
  const isAllChecked = childValues.every(Boolean);
  
  return (
    <FormGroup>
      <FormControlLabel
        control={
          <Checkbox
            checked={isAllChecked}
            indeterminate={isIndeterminate}
            onChange={handleParentChange}
          />
        }
        label="Parent"
      />
      {Object.keys(children).map(key => (
        <FormControlLabel
          key={key}
          control={
            <Checkbox
              checked={children[key]}
              onChange={handleChildChange}
              name={key}
            />
          }
          label={key}
          sx={{ ml: 3 }}
        />
      ))}
    </FormGroup>
  );
}

Accessibility Considerations

When implementing checkboxes, accessibility is crucial. MUI Checkbox already includes many accessibility features out of the box:

  1. It properly associates with labels using FormControlLabel or aria-label
  2. It supports keyboard navigation (Tab to focus, Space to toggle)
  3. It announces state changes to screen readers

To enhance accessibility further:


<Checkbox
  inputProps={{
    'aria-label': 'Mark task as complete',
    'aria-describedby': 'task-description-id'
  }}
/>

<span id="task-description-id" style={{ display: 'none' }}>
  Checking this box will mark the task as complete and move it to the completed tasks list
</span>

Introduction to Zustand for State Management

For our task list application, we'll use Zustand instead of React's built-in state management. Zustand is a lightweight state management library that makes it easy to create and access global state without the boilerplate of Redux or the complexity of Context API.

Why Zustand for Our Task List?

Zustand offers several advantages for our use case:

  1. Simplicity: Minimal boilerplate and straightforward API
  2. Performance: Prevents unnecessary re-renders
  3. Flexibility: Works well with both simple and complex state shapes
  4. DevTools Integration: Supports Redux DevTools for debugging
  5. TypeScript Support: Excellent type inference

Basic Zustand Store Setup

Let's set up a basic Zustand store for our task list:


import create from 'zustand';

const useTaskStore = create((set) => ({
  tasks: [],
  selectedTaskIds: [],
  
  // Actions
  addTask: (task) => set((state) => ({ 
    tasks: [...state.tasks, task] 
  })),
  
  toggleTaskSelection: (taskId) => set((state) => {
    const isSelected = state.selectedTaskIds.includes(taskId);
    return {
      selectedTaskIds: isSelected
        ? state.selectedTaskIds.filter(id => id !== taskId)
        : [...state.selectedTaskIds, taskId]
    };
  }),
  
  selectAllTasks: () => set((state) => ({
    selectedTaskIds: state.tasks.map(task => task.id)
  })),
  
  deselectAllTasks: () => set({ selectedTaskIds: [] }),
}));

export default useTaskStore;

Setting Up the Project

Now that we understand the core components, let's start building our task list application. We'll begin by setting up the project and installing the necessary dependencies.

Creating a New React Project

First, create a new React project using Create React App or Vite:


# Using Create React App
npx create-react-app task-list-app
cd task-list-app

# Or using Vite (recommended for faster development)
npm create vite@latest task-list-app -- --template react
cd task-list-app

Installing Dependencies

Next, install Material UI and Zustand:


npm install @mui/material @emotion/react @emotion/styled @mui/icons-material zustand uuid

We're installing:

  • @mui/material: The core Material UI library
  • @emotion/react and @emotion/styled: Required for MUI's styling system
  • @mui/icons-material: For using Material icons
  • zustand: For state management
  • uuid: For generating unique IDs for our tasks

Building the Task Store with Zustand

Let's create a more comprehensive Zustand store for our task list application. Create a new file called src/store/taskStore.js:


import { create } from 'zustand';
import { v4 as uuidv4 } from 'uuid';

const useTaskStore = create((set, get) => ({
  // State
  tasks: [],
  selectedTaskIds: [],
  
  // Computed properties (not stored in state but derived)
  get isAllSelected() {
    const { tasks, selectedTaskIds } = get();
    return tasks.length > 0 && selectedTaskIds.length === tasks.length;
  },
  
  get isPartiallySelected() {
    const { tasks, selectedTaskIds } = get();
    return selectedTaskIds.length > 0 && selectedTaskIds.length < tasks.length;
  },
  
  // Actions
  addTask: (title) => set((state) => ({ 
    tasks: [...state.tasks, {
      id: uuidv4(),
      title,
      completed: false,
      createdAt: new Date()
    }] 
  })),
  
  toggleTaskCompletion: (taskId) => set((state) => ({
    tasks: state.tasks.map(task => 
      task.id === taskId ? { ...task, completed: !task.completed } : task
    )
  })),
  
  removeTask: (taskId) => set((state) => ({
    tasks: state.tasks.filter(task => task.id !== taskId),
    selectedTaskIds: state.selectedTaskIds.filter(id => id !== taskId)
  })),
  
  removeTasks: (taskIds) => set((state) => ({
    tasks: state.tasks.filter(task => !taskIds.includes(task.id)),
    selectedTaskIds: state.selectedTaskIds.filter(id => !taskIds.includes(id))
  })),
  
  // Selection actions
  toggleTaskSelection: (taskId) => set((state) => {
    const isSelected = state.selectedTaskIds.includes(taskId);
    return {
      selectedTaskIds: isSelected
        ? state.selectedTaskIds.filter(id => id !== taskId)
        : [...state.selectedTaskIds, taskId]
    };
  }),
  
  toggleAllSelection: () => set((state) => {
    const allSelected = state.tasks.length === state.selectedTaskIds.length;
    
    return {
      selectedTaskIds: allSelected 
        ? [] 
        : state.tasks.map(task => task.id)
    };
  }),
  
  deselectAllTasks: () => set({ selectedTaskIds: [] }),
  
  // Bulk actions
  markSelectedAsCompleted: () => set((state) => ({
    tasks: state.tasks.map(task => 
      state.selectedTaskIds.includes(task.id)
        ? { ...task, completed: true }
        : task
    )
  })),
  
  markSelectedAsIncomplete: () => set((state) => ({
    tasks: state.tasks.map(task => 
      state.selectedTaskIds.includes(task.id)
        ? { ...task, completed: false }
        : task
    )
  })),
  
  deleteSelectedTasks: () => set((state) => ({
    tasks: state.tasks.filter(task => !state.selectedTaskIds.includes(task.id)),
    selectedTaskIds: []
  })),
}));

export default useTaskStore;

This store provides a comprehensive set of actions for:

  • Adding, toggling, and removing tasks
  • Selecting and deselecting individual or all tasks
  • Performing bulk actions on selected tasks
  • Computing derived state like "isAllSelected" and "isPartiallySelected"

Creating the Task List Component

Now, let's build the main TaskList component that will display our tasks and allow for selection. Create a new file called src/components/TaskList.jsx:


import { useState } from 'react';
import {
  Box,
  Checkbox,
  IconButton,
  List,
  ListItem,
  ListItemButton,
  ListItemIcon,
  ListItemText,
  Paper,
  TextField,
  Typography,
  Toolbar,
  Tooltip,
  Divider
} from '@mui/material';
import {
  Add as AddIcon,
  Delete as DeleteIcon,
  CheckCircle as CheckCircleIcon,
  RadioButtonUnchecked as UncheckedIcon
} from '@mui/icons-material';
import useTaskStore from '../store/taskStore';

function TaskList() {
  const [newTaskTitle, setNewTaskTitle] = useState('');
  
  // Access state and actions from our Zustand store
  const {
    tasks,
    selectedTaskIds,
    isAllSelected,
    isPartiallySelected,
    addTask,
    toggleTaskCompletion,
    toggleTaskSelection,
    toggleAllSelection,
    deleteSelectedTasks,
    markSelectedAsCompleted,
    markSelectedAsIncomplete
  } = useTaskStore();
  
  // Handle form submission for new task
  const handleAddTask = (e) => {
    e.preventDefault();
    if (newTaskTitle.trim()) {
      addTask(newTaskTitle.trim());
      setNewTaskTitle('');
    }
  };
  
  // Render empty state if no tasks
  if (tasks.length === 0) {
    return (
      <Paper sx={{ p: 3, mt: 3 }}>
        <Box sx={{ textAlign: 'center', py: 3 }}>
          <Typography variant="h6" color="text.secondary" gutterBottom>
            No tasks yet
          </Typography>
          <Typography variant="body2" color="text.secondary">
            Add a task to get started
          </Typography>
          
          <Box component="form" onSubmit={handleAddTask} sx={{ mt: 3, display: 'flex' }}>
            <TextField
              fullWidth
              variant="outlined"
              placeholder="Add a new task..."
              size="small"
              value={newTaskTitle}
              onChange={(e) => setNewTaskTitle(e.target.value)}
            />
            <IconButton type="submit" color="primary" sx={{ ml: 1 }}>
              <AddIcon />
            </IconButton>
          </Box>
        </Box>
      </Paper>
    );
  }
  
  return (
    <Paper sx={{ mt: 3 }}>
      {/* Task list toolbar with bulk actions */}
      <Toolbar
        variant="dense"
        sx={{
          pl: { sm: 2 },
          pr: { xs: 1, sm: 1 },
          bgcolor: selectedTaskIds.length > 0 ? 'primary.light' : 'transparent',
          color: selectedTaskIds.length > 0 ? 'primary.contrastText' : 'inherit',
        }}
      >
        <Tooltip title={isAllSelected ? "Deselect all" : "Select all"}>
          <Checkbox
            indeterminate={isPartiallySelected}
            checked={isAllSelected}
            onChange={toggleAllSelection}
            inputProps={{ 'aria-label': 'select all tasks' }}
            sx={{
              color: selectedTaskIds.length > 0 ? 'inherit' : undefined,
              '&.Mui-checked': {
                color: selectedTaskIds.length > 0 ? 'inherit' : undefined,
              },
            }}
          />
        </Tooltip>
        
        <Typography
          sx={{ flex: '1 1 100%', ml: 1 }}
          variant="subtitle1"
          component="div"
        >
          {selectedTaskIds.length > 0 
            ? `${selectedTaskIds.length} selected` 
            : 'Tasks'}
        </Typography>
        
        {selectedTaskIds.length > 0 && (
          <>
            <Tooltip title="Mark as complete">
              <IconButton onClick={markSelectedAsCompleted}>
                <CheckCircleIcon />
              </IconButton>
            </Tooltip>
            <Tooltip title="Mark as incomplete">
              <IconButton onClick={markSelectedAsIncomplete}>
                <UncheckedIcon />
              </IconButton>
            </Tooltip>
            <Tooltip title="Delete">
              <IconButton onClick={deleteSelectedTasks}>
                <DeleteIcon />
              </IconButton>
            </Tooltip>
          </>
        )}
      </Toolbar>
      
      <Divider />
      
      {/* Task list */}
      <List sx={{ width: '100%', bgcolor: 'background.paper' }}>
        {tasks.map((task) => {
          const isSelected = selectedTaskIds.includes(task.id);
          
          return (
            <ListItem
              key={task.id}
              disablePadding
              secondaryAction={
                <IconButton 
                  edge="end" 
                  aria-label="delete"
                  onClick={() => toggleTaskCompletion(task.id)}
                >
                  {task.completed 
                    ? <CheckCircleIcon color="success" /> 
                    : <UncheckedIcon color="action" />}
                </IconButton>
              }
            >
              <ListItemButton 
                role={undefined} 
                dense
                onClick={() => toggleTaskSelection(task.id)}
                selected={isSelected}
              >
                <ListItemIcon>
                  <Checkbox
                    edge="start"
                    checked={isSelected}
                    tabIndex={-1}
                    disableRipple
                    inputProps={{ 'aria-labelledby': `task-${task.id}` }}
                  />
                </ListItemIcon>
                <ListItemText
                  id={`task-${task.id}`}
                  primary={task.title}
                  sx={{
                    textDecoration: task.completed ? 'line-through' : 'none',
                    color: task.completed ? 'text.secondary' : 'text.primary',
                  }}
                />
              </ListItemButton>
            </ListItem>
          );
        })}
      </List>
      
      {/* Add new task form */}
      <Box component="form" onSubmit={handleAddTask} sx={{ p: 2, display: 'flex' }}>
        <TextField
          fullWidth
          variant="outlined"
          placeholder="Add a new task..."
          size="small"
          value={newTaskTitle}
          onChange={(e) => setNewTaskTitle(e.target.value)}
        />
        <IconButton type="submit" color="primary" sx={{ ml: 1 }}>
          <AddIcon />
        </IconButton>
      </Box>
    </Paper>
  );
}

export default TaskList;

This component:

  1. Displays a list of tasks with checkboxes for selection
  2. Shows a toolbar with bulk actions when tasks are selected
  3. Provides a form for adding new tasks
  4. Handles empty state with a friendly message
  5. Visually indicates task completion status

Creating the Main App Component

Now, let's update the main App component to use our TaskList. Modify src/App.jsx:


import { Container, Typography, Box, CssBaseline, ThemeProvider, createTheme } from '@mui/material';
import TaskList from './components/TaskList';

// Create a theme instance
const theme = createTheme({
  palette: {
    primary: {
      main: '#1976d2',
    },
    secondary: {
      main: '#dc004e',
    },
  },
});

function App() {
  return (
    <ThemeProvider theme={theme}>
      <CssBaseline />
      <Container maxWidth="md">
        <Box sx={{ my: 4 }}>
          <Typography variant="h4" component="h1" gutterBottom>
            Task Manager
          </Typography>
          <Typography variant="subtitle1" color="text.secondary" gutterBottom>
            Create, manage, and track your tasks with ease
          </Typography>
          
          <TaskList />
        </Box>
      </Container>
    </ThemeProvider>
  );
}

export default App;

Enhancing the Task List with Additional Features

Let's enhance our task list with additional features like filtering, sorting, and task categories. Create a new file called src/components/EnhancedTaskList.jsx:


import { useState, useMemo } from 'react';
import {
  Box,
  Checkbox,
  IconButton,
  List,
  ListItem,
  ListItemButton,
  ListItemIcon,
  ListItemText,
  Paper,
  TextField,
  Typography,
  Toolbar,
  Tooltip,
  Divider,
  FormControl,
  InputLabel,
  Select,
  MenuItem,
  Stack,
  Chip
} from '@mui/material';
import {
  Add as AddIcon,
  Delete as DeleteIcon,
  CheckCircle as CheckCircleIcon,
  RadioButtonUnchecked as UncheckedIcon,
  Sort as SortIcon
} from '@mui/icons-material';
import useTaskStore from '../store/taskStore';

function EnhancedTaskList() {
  const [newTaskTitle, setNewTaskTitle] = useState('');
  const [filter, setFilter] = useState('all'); // 'all', 'active', 'completed'
  const [sortBy, setSortBy] = useState('createdAt'); // 'createdAt', 'title'
  
  // Access state and actions from our Zustand store
  const {
    tasks,
    selectedTaskIds,
    isAllSelected,
    isPartiallySelected,
    addTask,
    toggleTaskCompletion,
    toggleTaskSelection,
    toggleAllSelection,
    deleteSelectedTasks,
    markSelectedAsCompleted,
    markSelectedAsIncomplete
  } = useTaskStore();
  
  // Apply filtering and sorting
  const filteredAndSortedTasks = useMemo(() => {
    // First, filter the tasks
    let result = [...tasks];
    if (filter === 'active') {
      result = result.filter(task => !task.completed);
    } else if (filter === 'completed') {
      result = result.filter(task => task.completed);
    }
    
    // Then, sort the tasks
    result.sort((a, b) => {
      if (sortBy === 'title') {
        return a.title.localeCompare(b.title);
      } else if (sortBy === 'createdAt') {
        return new Date(b.createdAt) - new Date(a.createdAt); // newest first
      }
      return 0;
    });
    
    return result;
  }, [tasks, filter, sortBy]);
  
  // Handle form submission for new task
  const handleAddTask = (e) => {
    e.preventDefault();
    if (newTaskTitle.trim()) {
      addTask(newTaskTitle.trim());
      setNewTaskTitle('');
    }
  };
  
  // Calculate stats
  const totalTasks = tasks.length;
  const completedTasks = tasks.filter(task => task.completed).length;
  const activeTasks = totalTasks - completedTasks;
  
  // Render empty state if no tasks
  if (tasks.length === 0) {
    return (
      <Paper sx={{ p: 3, mt: 3 }}>
        <Box sx={{ textAlign: 'center', py: 3 }}>
          <Typography variant="h6" color="text.secondary" gutterBottom>
            No tasks yet
          </Typography>
          <Typography variant="body2" color="text.secondary">
            Add a task to get started
          </Typography>
          
          <Box component="form" onSubmit={handleAddTask} sx={{ mt: 3, display: 'flex' }}>
            <TextField
              fullWidth
              variant="outlined"
              placeholder="Add a new task..."
              size="small"
              value={newTaskTitle}
              onChange={(e) => setNewTaskTitle(e.target.value)}
            />
            <IconButton type="submit" color="primary" sx={{ ml: 1 }}>
              <AddIcon />
            </IconButton>
          </Box>
        </Box>
      </Paper>
    );
  }
  
  return (
    <Paper sx={{ mt: 3 }}>
      {/* Task list toolbar with bulk actions */}
      <Toolbar
        variant="dense"
        sx={{
          pl: { sm: 2 },
          pr: { xs: 1, sm: 1 },
          bgcolor: selectedTaskIds.length > 0 ? 'primary.light' : 'transparent',
          color: selectedTaskIds.length > 0 ? 'primary.contrastText' : 'inherit',
        }}
      >
        <Tooltip title={isAllSelected ? "Deselect all" : "Select all"}>
          <Checkbox
            indeterminate={isPartiallySelected}
            checked={isAllSelected}
            onChange={toggleAllSelection}
            inputProps={{ 'aria-label': 'select all tasks' }}
            sx={{
              color: selectedTaskIds.length > 0 ? 'inherit' : undefined,
              '&.Mui-checked': {
                color: selectedTaskIds.length > 0 ? 'inherit' : undefined,
              },
            }}
          />
        </Tooltip>
        
        <Typography
          sx={{ flex: '1 1 100%', ml: 1 }}
          variant="subtitle1"
          component="div"
        >
          {selectedTaskIds.length > 0 
            ? `${selectedTaskIds.length} selected` 
            : 'Tasks'}
        </Typography>
        
        {selectedTaskIds.length > 0 ? (
          <>
            <Tooltip title="Mark as complete">
              <IconButton onClick={markSelectedAsCompleted}>
                <CheckCircleIcon />
              </IconButton>
            </Tooltip>
            <Tooltip title="Mark as incomplete">
              <IconButton onClick={markSelectedAsIncomplete}>
                <UncheckedIcon />
              </IconButton>
            </Tooltip>
            <Tooltip title="Delete">
              <IconButton onClick={deleteSelectedTasks}>
                <DeleteIcon />
              </IconButton>
            </Tooltip>
          </>
        ) : (
          <>
            <Stack direction="row" spacing={1} sx={{ mr: 2 }}>
              <Chip 
                label={`All: ${totalTasks}`} 
                variant={filter === 'all' ? 'filled' : 'outlined'}
                onClick={() => setFilter('all')}
                size="small"
              />
              <Chip 
                label={`Active: ${activeTasks}`} 
                variant={filter === 'active' ? 'filled' : 'outlined'}
                onClick={() => setFilter('active')}
                color="primary"
                size="small"
              />
              <Chip 
                label={`Completed: ${completedTasks}`} 
                variant={filter === 'completed' ? 'filled' : 'outlined'}
                onClick={() => setFilter('completed')}
                color="success"
                size="small"
              />
            </Stack>
            
            <FormControl size="small" sx={{ minWidth: 120 }}>
              <Select
                value={sortBy}
                onChange={(e) => setSortBy(e.target.value)}
                displayEmpty
                startAdornment={<SortIcon fontSize="small" sx={{ mr: 1 }} />}
              >
                <MenuItem value="createdAt">Newest first</MenuItem>
                <MenuItem value="title">Alphabetical</MenuItem>
              </Select>
            </FormControl>
          </>
        )}
      </Toolbar>
      
      <Divider />
      
      {/* Task list */}
      <List sx={{ width: '100%', bgcolor: 'background.paper' }}>
        {filteredAndSortedTasks.map((task) => {
          const isSelected = selectedTaskIds.includes(task.id);
          
          return (
            <ListItem
              key={task.id}
              disablePadding
              secondaryAction={
                <IconButton 
                  edge="end" 
                  aria-label={task.completed ? "mark as incomplete" : "mark as complete"}
                  onClick={() => toggleTaskCompletion(task.id)}
                >
                  {task.completed 
                    ? <CheckCircleIcon color="success" /> 
                    : <UncheckedIcon color="action" />}
                </IconButton>
              }
            >
              <ListItemButton 
                role={undefined} 
                dense
                onClick={() => toggleTaskSelection(task.id)}
                selected={isSelected}
              >
                <ListItemIcon>
                  <Checkbox
                    edge="start"
                    checked={isSelected}
                    tabIndex={-1}
                    disableRipple
                    inputProps={{ 
                      'aria-labelledby': `task-${task.id}`,
                      'aria-label': `Select task: ${task.title}`
                    }}
                  />
                </ListItemIcon>
                <ListItemText
                  id={`task-${task.id}`}
                  primary={task.title}
                  secondary={new Date(task.createdAt).toLocaleDateString()}
                  sx={{
                    textDecoration: task.completed ? 'line-through' : 'none',
                    color: task.completed ? 'text.secondary' : 'text.primary',
                  }}
                />
              </ListItemButton>
            </ListItem>
          );
        })}
      </List>
      
      {/* Add new task form */}
      <Box component="form" onSubmit={handleAddTask} sx={{ p: 2, display: 'flex' }}>
        <TextField
          fullWidth
          variant="outlined"
          placeholder="Add a new task..."
          size="small"
          value={newTaskTitle}
          onChange={(e) => setNewTaskTitle(e.target.value)}
          inputProps={{
            'aria-label': 'Add a new task',
          }}
        />
        <IconButton 
          type="submit" 
          color="primary" 
          sx={{ ml: 1 }}
          aria-label="Add task"
        >
          <AddIcon />
        </IconButton>
      </Box>
    </Paper>
  );
}

export default EnhancedTaskList;

This enhanced version adds:

  1. Filtering tasks by status (all, active, completed)
  2. Sorting tasks by creation date or title
  3. Task statistics with visual indicators
  4. Improved accessibility with descriptive labels
  5. Date display for each task

Persisting State with Zustand Middleware

To make our task list even more useful, let's add persistence so tasks remain after page refresh. We'll use Zustand's middleware for this. Update src/store/taskStore.js:


import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { v4 as uuidv4 } from 'uuid';

const useTaskStore = create(
  persist(
    (set, get) => ({
      // State
      tasks: [],
      selectedTaskIds: [],
      
      // Computed properties (not stored in state but derived)
      get isAllSelected() {
        const { tasks, selectedTaskIds } = get();
        return tasks.length > 0 && selectedTaskIds.length === tasks.length;
      },
      
      get isPartiallySelected() {
        const { tasks, selectedTaskIds } = get();
        return selectedTaskIds.length > 0 && selectedTaskIds.length < tasks.length;
      },
      
      // Actions
      addTask: (title) => set((state) => ({ 
        tasks: [...state.tasks, {
          id: uuidv4(),
          title,
          completed: false,
          createdAt: new Date().toISOString()
        }] 
      })),
      
      toggleTaskCompletion: (taskId) => set((state) => ({
        tasks: state.tasks.map(task => 
          task.id === taskId ? { ...task, completed: !task.completed } : task
        )
      })),
      
      removeTask: (taskId) => set((state) => ({
        tasks: state.tasks.filter(task => task.id !== taskId),
        selectedTaskIds: state.selectedTaskIds.filter(id => id !== taskId)
      })),
      
      removeTasks: (taskIds) => set((state) => ({
        tasks: state.tasks.filter(task => !taskIds.includes(task.id)),
        selectedTaskIds: state.selectedTaskIds.filter(id => !taskIds.includes(id))
      })),
      
      // Selection actions
      toggleTaskSelection: (taskId) => set((state) => {
        const isSelected = state.selectedTaskIds.includes(taskId);
        return {
          selectedTaskIds: isSelected
            ? state.selectedTaskIds.filter(id => id !== taskId)
            : [...state.selectedTaskIds, taskId]
        };
      }),
      
      toggleAllSelection: () => set((state) => {
        const allSelected = state.tasks.length === state.selectedTaskIds.length;
        
        return {
          selectedTaskIds: allSelected 
            ? [] 
            : state.tasks.map(task => task.id)
        };
      }),
      
      deselectAllTasks: () => set({ selectedTaskIds: [] }),
      
      // Bulk actions
      markSelectedAsCompleted: () => set((state) => ({
        tasks: state.tasks.map(task => 
          state.selectedTaskIds.includes(task.id)
            ? { ...task, completed: true }
            : task
        )
      })),
      
      markSelectedAsIncomplete: () => set((state) => ({
        tasks: state.tasks.map(task => 
          state.selectedTaskIds.includes(task.id)
            ? { ...task, completed: false }
            : task
        )
      })),
      
      deleteSelectedTasks: () => set((state) => ({
        tasks: state.tasks.filter(task => !state.selectedTaskIds.includes(task.id)),
        selectedTaskIds: []
      })),
    }),
    {
      name: 'task-storage', // unique name for localStorage
      partialize: (state) => ({ tasks: state.tasks }), // only persist tasks, not selection state
    }
  )
);

export default useTaskStore;

The persist middleware automatically saves our tasks to localStorage, so they'll be available even after the user refreshes the page. We're using the partialize option to only persist the tasks themselves, not the selection state.

Optimizing Performance

Our task list works well for small to medium-sized lists, but we should optimize it for larger lists. Let's add some performance enhancements:

1. Virtualized List for Large Task Sets

For handling hundreds or thousands of tasks, we can use virtualization to only render visible items:


import { useState, useMemo } from 'react';
import {
  Box,
  Checkbox,
  IconButton,
  ListItem,
  ListItemButton,
  ListItemIcon,
  ListItemText,
  Paper,
  TextField,
  Typography,
  Toolbar,
  Tooltip,
  Divider,
  Stack,
  Chip
} from '@mui/material';
import { FixedSizeList } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
import {
  Add as AddIcon,
  Delete as DeleteIcon,
  CheckCircle as CheckCircleIcon,
  RadioButtonUnchecked as UncheckedIcon
} from '@mui/icons-material';
import useTaskStore from '../store/taskStore';

function VirtualizedTaskList() {
  const [newTaskTitle, setNewTaskTitle] = useState('');
  
  // Access state and actions from our Zustand store
  const {
    tasks,
    selectedTaskIds,
    isAllSelected,
    isPartiallySelected,
    addTask,
    toggleTaskCompletion,
    toggleTaskSelection,
    toggleAllSelection,
    deleteSelectedTasks,
    markSelectedAsCompleted,
    markSelectedAsIncomplete
  } = useTaskStore();
  
  // Handle form submission for new task
  const handleAddTask = (e) => {
    e.preventDefault();
    if (newTaskTitle.trim()) {
      addTask(newTaskTitle.trim());
      setNewTaskTitle('');
    }
  };
  
  // Calculate stats
  const totalTasks = tasks.length;
  const completedTasks = tasks.filter(task => task.completed).length;
  const activeTasks = totalTasks - completedTasks;
  
  // Render task row (for virtualized list)
  const renderTask = ({ index, style }) => {
    const task = tasks[index];
    const isSelected = selectedTaskIds.includes(task.id);
    
    return (
      <ListItem
        style={style}
        key={task.id}
        disablePadding
        secondaryAction={
          <IconButton 
            edge="end" 
            aria-label={task.completed ? "mark as incomplete" : "mark as complete"}
            onClick={() => toggleTaskCompletion(task.id)}
          >
            {task.completed 
              ? <CheckCircleIcon color="success" /> 
              : <UncheckedIcon color="action" />}
          </IconButton>
        }
      >
        <ListItemButton 
          role={undefined} 
          dense
          onClick={() => toggleTaskSelection(task.id)}
          selected={isSelected}
        >
          <ListItemIcon>
            <Checkbox
              edge="start"
              checked={isSelected}
              tabIndex={-1}
              disableRipple
              inputProps={{ 
                'aria-labelledby': `task-${task.id}`,
                'aria-label': `Select task: ${task.title}`
              }}
            />
          </ListItemIcon>
          <ListItemText
            id={`task-${task.id}`}
            primary={task.title}
            secondary={new Date(task.createdAt).toLocaleDateString()}
            sx={{
              textDecoration: task.completed ? 'line-through' : 'none',
              color: task.completed ? 'text.secondary' : 'text.primary',
            }}
          />
        </ListItemButton>
      </ListItem>
    );
  };
  
  // Render empty state if no tasks
  if (tasks.length === 0) {
    return (
      <Paper sx={{ p: 3, mt: 3 }}>
        <Box sx={{ textAlign: 'center', py: 3 }}>
          <Typography variant="h6" color="text.secondary" gutterBottom>
            No tasks yet
          </Typography>
          <Typography variant="body2" color="text.secondary">
            Add a task to get started
          </Typography>
          
          <Box component="form" onSubmit={handleAddTask} sx={{ mt: 3, display: 'flex' }}>
            <TextField
              fullWidth
              variant="outlined"
              placeholder="Add a new task..."
              size="small"
              value={newTaskTitle}
              onChange={(e) => setNewTaskTitle(e.target.value)}
            />
            <IconButton type="submit" color="primary" sx={{ ml: 1 }}>
              <AddIcon />
            </IconButton>
          </Box>
        </Box>
      </Paper>
    );
  }
  
  return (
    <Paper sx={{ mt: 3, height: 500 }}>
      {/* Task list toolbar with bulk actions */}
      <Toolbar
        variant="dense"
        sx={{
          pl: { sm: 2 },
          pr: { xs: 1, sm: 1 },
          bgcolor: selectedTaskIds.length > 0 ? 'primary.light' : 'transparent',
          color: selectedTaskIds.length > 0 ? 'primary.contrastText' : 'inherit',
        }}
      >
        <Tooltip title={isAllSelected ? "Deselect all" : "Select all"}>
          <Checkbox
            indeterminate={isPartiallySelected}
            checked={isAllSelected}
            onChange={toggleAllSelection}
            inputProps={{ 'aria-label': 'select all tasks' }}
            sx={{
              color: selectedTaskIds.length > 0 ? 'inherit' : undefined,
              '&.Mui-checked': {
                color: selectedTaskIds.length > 0 ? 'inherit' : undefined,
              },
            }}
          />
        </Tooltip>
        
        <Typography
          sx={{ flex: '1 1 100%', ml: 1 }}
          variant="subtitle1"
          component="div"
        >
          {selectedTaskIds.length > 0 
            ? `${selectedTaskIds.length} selected` 
            : 'Tasks'}
        </Typography>
        
        {selectedTaskIds.length > 0 ? (
          <>
            <Tooltip title="Mark as complete">
              <IconButton onClick={markSelectedAsCompleted}>
                <CheckCircleIcon />
              </IconButton>
            </Tooltip>
            <Tooltip title="Mark as incomplete">
              <IconButton onClick={markSelectedAsIncomplete}>
                <UncheckedIcon />
              </IconButton>
            </Tooltip>
            <Tooltip title="Delete">
              <IconButton onClick={deleteSelectedTasks}>
                <DeleteIcon />
              </IconButton>
            </Tooltip>
          </>
        ) : (
          <Stack direction="row" spacing={1}>
            <Chip 
              label={`All: ${totalTasks}`} 
              size="small"
            />
            <Chip 
              label={`Active: ${activeTasks}`} 
              color="primary"
              size="small"
            />
            <Chip 
              label={`Completed: ${completedTasks}`} 
              color="success"
              size="small"
            />
          </Stack>
        )}
      </Toolbar>
      
      <Divider />
      
      {/* Virtualized task list */}
      <Box sx={{ height: 'calc(100% - 120px)' }}>
        <AutoSizer>
          {({ height, width }) => (
            <FixedSizeList
              height={height}
              width={width}
              itemSize={60}
              itemCount={tasks.length}
              overscanCount={5}
            >
              {renderTask}
            </FixedSizeList>
          )}
        </AutoSizer>
      </Box>
      
      {/* Add new task form */}
      <Box component="form" onSubmit={handleAddTask} sx={{ p: 2, display: 'flex' }}>
        <TextField
          fullWidth
          variant="outlined"
          placeholder="Add a new task..."
          size="small"
          value={newTaskTitle}
          onChange={(e) => setNewTaskTitle(e.target.value)}
          inputProps={{
            'aria-label': 'Add a new task',
          }}
        />
        <IconButton 
          type="submit" 
          color="primary" 
          sx={{ ml: 1 }}
          aria-label="Add task"
        >
          <AddIcon />
        </IconButton>
      </Box>
    </Paper>
  );
}

export default VirtualizedTaskList;

2. Memoizing Components

For better performance, we can memoize individual task items to prevent unnecessary re-renders:


import { memo } from 'react';
import {
  Checkbox,
  IconButton,
  ListItem,
  ListItemButton,
  ListItemIcon,
  ListItemText,
} from '@mui/material';
import {
  CheckCircle as CheckCircleIcon,
  RadioButtonUnchecked as UncheckedIcon
} from '@mui/icons-material';

// Memoized task item component
const TaskItem = memo(({ 
  task, 
  isSelected, 
  onToggleSelection, 
  onToggleCompletion 
}) => {
  return (
    <ListItem
      disablePadding
      secondaryAction={
        <IconButton 
          edge="end" 
          aria-label={task.completed ? "mark as incomplete" : "mark as complete"}
          onClick={() => onToggleCompletion(task.id)}
        >
          {task.completed 
            ? <CheckCircleIcon color="success" /> 
            : <UncheckedIcon color="action" />}
        </IconButton>
      }
    >
      <ListItemButton 
        role={undefined} 
        dense
        onClick={() => onToggleSelection(task.id)}
        selected={isSelected}
      >
        <ListItemIcon>
          <Checkbox
            edge="start"
            checked={isSelected}
            tabIndex={-1}
            disableRipple
            inputProps={{ 
              'aria-labelledby': `task-${task.id}`,
              'aria-label': `Select task: ${task.title}`
            }}
          />
        </ListItemIcon>
        <ListItemText
          id={`task-${task.id}`}
          primary={task.title}
          secondary={new Date(task.createdAt).toLocaleDateString()}
          sx={{
            textDecoration: task.completed ? 'line-through' : 'none',
            color: task.completed ? 'text.secondary' : 'text.primary',
          }}
        />
      </ListItemButton>
    </ListItem>
  );
});

export default TaskItem;

Advanced Checkbox Customization

Let's explore some advanced customization options for the MUI Checkbox component:

Custom Checkbox Styles and Icons


import { styled } from '@mui/material/styles';
import { Checkbox } from '@mui/material';
import { Star, StarBorder } from '@mui/icons-material';

// Custom styled checkbox
const StyledCheckbox = styled(Checkbox)(({ theme }) => ({
  color: theme.palette.grey[400],
  '&.Mui-checked': {
    color: theme.palette.warning.main,
  },
  '&:hover': {
    backgroundColor: 'rgba(255, 152, 0, 0.08)',
  },
}));

// Usage example
function CustomCheckboxes() {
  return (
    <div>
      {/* Custom color checkbox */}
      <Checkbox 
        sx={{ 
          color: 'purple',
          '&.Mui-checked': {
            color: 'purple',
          },
        }} 
      />
      
      {/* Custom icon checkbox */}
      <Checkbox 
        icon={<StarBorder />} 
        checkedIcon={<Star />} 
        color="warning"
      />
      
      {/* Styled checkbox using styled API */}
      <StyledCheckbox />
    </div>
  );
}

Checkbox with Form Control and Label


import {
  FormControl,
  FormControlLabel,
  FormGroup,
  FormLabel,
  FormHelperText,
  Checkbox
} from '@mui/material';

function CheckboxWithLabel() {
  return (
    <FormControl component="fieldset" variant="standard">
      <FormLabel component="legend">Task Categories</FormLabel>
      <FormGroup>
        <FormControlLabel
          control={<Checkbox name="work" />}
          label="Work"
        />
        <FormControlLabel
          control={<Checkbox name="personal" color="secondary" />}
          label="Personal"
        />
        <FormControlLabel
          control={<Checkbox name="shopping" color="success" />}
          label="Shopping"
        />
      </FormGroup>
      <FormHelperText>Select task categories to filter</FormHelperText>
    </FormControl>
  );
}

Best Practices and Common Issues

Best Practices for MUI Checkboxes

  1. Always Use Labels: Checkboxes should always have a clear label, either through FormControlLabel or with proper aria-label attributes.

  2. Group Related Checkboxes: Use FormGroup to semantically group related checkboxes.

  3. Controlled vs. Uncontrolled: Prefer controlled components for complex forms where you need to manage state.

  4. Handle Indeterminate State Properly: For parent-child relationships, calculate and update the indeterminate state correctly.

  5. Keyboard Accessibility: Ensure checkboxes can be navigated and toggled using the keyboard (Tab and Space).

  6. Visual Feedback: Provide clear visual feedback for all states (checked, unchecked, indeterminate, disabled, focused).

  7. Consistent Sizing: Use the size prop consistently across your application.

Common Issues and Solutions

  1. Issue: Checkbox state not updating when clicked Solution: Make sure you're correctly handling the onChange event and updating state

// Incorrect
<Checkbox checked={isChecked} />

// Correct
<Checkbox 
  checked={isChecked} 
  onChange={(e) => setIsChecked(e.target.checked)} 
/>
  1. Issue: Indeterminate state not showing visually Solution: The indeterminate prop is independent of checked and must be set explicitly

// Correct usage
<Checkbox 
  checked={allChecked} 
  indeterminate={someChecked && !allChecked}
  onChange={handleParentChange}
/>
  1. Issue: Performance issues with many checkboxes Solution: Use virtualization for long lists and memoize checkbox components

  2. Issue: Checkbox size inconsistency Solution: Explicitly set the size prop on all checkboxes


<Checkbox size="small" />
  1. Issue: Custom styles being overridden by MUI Solution: Use higher specificity selectors or the sx prop with the right selector

<Checkbox 
  sx={{
    '&.Mui-checked': {
      color: '#ff5722',
    },
    '&.MuiCheckbox-root': {
      padding: '4px',
    },
  }}
/>

Accessibility Considerations

When working with checkboxes, accessibility is crucial. Here are some important accessibility enhancements:

  1. Proper Labeling: Always associate checkboxes with labels

// Good - using FormControlLabel
<FormControlLabel
  control={<Checkbox />}
  label="Enable notifications"
/>

// Good - using aria-label
<Checkbox aria-label="Enable notifications" />

// Bad - no label
<Checkbox />
  1. Focus Indicators: Ensure focus indicators are visible

<Checkbox 
  sx={{
    '&.Mui-focusVisible': {
      outline: '2px solid #1976d2',
      outlineOffset: '2px',
    },
  }}
/>
  1. Keyboard Navigation: Test that your checkboxes can be navigated and toggled with Tab and Space keys

  2. Group Related Checkboxes: Use FormGroup and FormLabel to create semantic groups


<FormControl component="fieldset">
  <FormLabel component="legend">Notification preferences</FormLabel>
  <FormGroup>
    <FormControlLabel control={<Checkbox />} label="Email" />
    <FormControlLabel control={<Checkbox />} label="SMS" />
    <FormControlLabel control={<Checkbox />} label="Push" />
  </FormGroup>
</FormControl>
  1. Descriptive Text: Add helper text for additional context

<FormControl component="fieldset">
  <FormLabel component="legend">Privacy settings</FormLabel>
  <FormGroup>
    <FormControlLabel 
      control={<Checkbox />} 
      label="Share usage data" 
    />
  </FormGroup>
  <FormHelperText>
    This helps us improve our services but is completely optional
  </FormHelperText>
</FormControl>

Wrapping Up

We've built a fully functional task list application with MUI Checkbox and Zustand that demonstrates how to implement selectable items with efficient state management. We've covered everything from basic checkbox usage to advanced features like bulk selection, filtering, and virtualization for performance.

By combining MUI's powerful components with Zustand's simple yet effective state management, we've created a solution that's both user-friendly and developer-friendly. The patterns shown here can be applied to many different types of applications, from simple todo lists to complex data tables with selection capabilities.