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.
Prop | Type | Default | Description |
---|---|---|---|
checked | boolean | - | If true, the component is checked (controlled component) |
defaultChecked | boolean | false | The default checked state (uncontrolled component) |
disabled | boolean | false | If true, the component is disabled |
indeterminate | boolean | false | If true, the component appears indeterminate |
onChange | function | - | Callback fired when the state changes |
color | string | 'primary' | The color of the component ('primary', 'secondary', 'error', 'info', 'success', 'warning', or custom) |
size | string | 'medium' | The size of the component ('small', 'medium', 'large') |
icon | node | - | The icon to display when the component is unchecked |
checkedIcon | node | - | The icon to display when the component is checked |
required | boolean | false | If 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:
- It properly associates with labels using FormControlLabel or aria-label
- It supports keyboard navigation (Tab to focus, Space to toggle)
- 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:
- Simplicity: Minimal boilerplate and straightforward API
- Performance: Prevents unnecessary re-renders
- Flexibility: Works well with both simple and complex state shapes
- DevTools Integration: Supports Redux DevTools for debugging
- 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 iconszustand
: For state managementuuid
: 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:
- Displays a list of tasks with checkboxes for selection
- Shows a toolbar with bulk actions when tasks are selected
- Provides a form for adding new tasks
- Handles empty state with a friendly message
- 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:
- Filtering tasks by status (all, active, completed)
- Sorting tasks by creation date or title
- Task statistics with visual indicators
- Improved accessibility with descriptive labels
- 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
-
Always Use Labels: Checkboxes should always have a clear label, either through FormControlLabel or with proper aria-label attributes.
-
Group Related Checkboxes: Use FormGroup to semantically group related checkboxes.
-
Controlled vs. Uncontrolled: Prefer controlled components for complex forms where you need to manage state.
-
Handle Indeterminate State Properly: For parent-child relationships, calculate and update the indeterminate state correctly.
-
Keyboard Accessibility: Ensure checkboxes can be navigated and toggled using the keyboard (Tab and Space).
-
Visual Feedback: Provide clear visual feedback for all states (checked, unchecked, indeterminate, disabled, focused).
-
Consistent Sizing: Use the size prop consistently across your application.
Common Issues and Solutions
- 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)}
/>
- 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}
/>
-
Issue: Performance issues with many checkboxes Solution: Use virtualization for long lists and memoize checkbox components
-
Issue: Checkbox size inconsistency Solution: Explicitly set the size prop on all checkboxes
<Checkbox size="small" />
- 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:
- 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 />
- Focus Indicators: Ensure focus indicators are visible
<Checkbox
sx={{
'&.Mui-focusVisible': {
outline: '2px solid #1976d2',
outlineOffset: '2px',
},
}}
/>
-
Keyboard Navigation: Test that your checkboxes can be navigated and toggled with Tab and Space keys
-
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>
- 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.