Building a Custom Category Filter Dropdown with React MUI Select and Formik
As a front-end developer, creating intuitive filter interfaces is something I work with almost daily. One of the most common UI patterns is the category filter dropdown - a component that lets users filter content by selecting from predefined categories. Material UI's Select component combined with Formik makes this particularly elegant to implement.
In this guide, I'll walk you through building a robust, reusable category filter dropdown that handles real-world requirements: multiple selections, form integration, custom styling, and proper accessibility support. By the end, you'll have a production-ready component you can adapt to any project.
Learning Objectives
After completing this tutorial, you'll be able to:
- Implement a fully functional category filter using MUI Select and Formik
- Configure controlled vs uncontrolled Select components for different use cases
- Properly handle form state and validation with Formik integration
- Customize the Select component's appearance using MUI's styling options
- Implement accessibility features for inclusive user experiences
- Optimize performance for large datasets with virtualization
Understanding MUI Select Component
Before diving into implementation, let's understand what makes the MUI Select component powerful and how it fits into the Material UI ecosystem.
Core Functionality and Variants
The Select component in Material UI provides a way to select a value from a list of options. It's essentially an enhanced dropdown that follows Material Design principles. The component supports single and multiple selections, different visual variants, and integrates seamlessly with MUI's form components.
MUI offers three main variants of the Select component:
- Filled - Has a solid background and bottom border
- Outlined - Has a visible outline around the entire component
- Standard - Has only a bottom border in its default state
Each variant serves different design needs while maintaining the same core functionality. For our category filter, we'll use the outlined variant as it provides clear visual boundaries that work well for filters.
Select Component Deep Dive
The Select component is built on top of the Input component and integrates with MUI's FormControl system. This architecture provides a consistent form experience while allowing extensive customization.
Essential Props
Prop | Type | Default | Description |
---|---|---|---|
value | any | - | The selected value(s). Required for controlled components. |
onChange | function | - | Callback fired when a menu item is selected. |
multiple | boolean | false | If true, multiple values can be selected. |
displayEmpty | boolean | false | If true, the selected item is displayed even if its value is empty. |
renderValue | function | - | Function to customize the display value. |
MenuProps | object | - | Props applied to the Menu component. |
variant | 'standard' | 'outlined' | 'filled' | 'standard' | The variant to use. |
label | node | - | The label to display. |
Controlled vs Uncontrolled Usage
Like most React form components, Select can be used in either controlled or uncontrolled mode:
Controlled Mode: You manage the component's state externally by providing both value
and onChange
props. This gives you complete control over the component's behavior.
Uncontrolled Mode: The component manages its own state internally. You provide a defaultValue
prop for the initial value, and the component handles state changes internally.
For our category filter, we'll use the controlled mode with Formik, as this gives us the most flexibility and integrates well with form management.
Child Components
The Select component works with MenuItem components to define the available options. Each MenuItem represents a selectable option in the dropdown menu. For more complex layouts, you can also use ListSubheader to group related options.
Customization Options
MUI Select offers multiple customization paths:
- Styling with the
sx
prop: For direct, component-specific styling - Theme customization: For app-wide consistent styling
- Custom component overrides: For deeper structural changes
For our filter, we'll use a combination of these approaches to create a polished, on-brand component.
Accessibility Features
The MUI Select component comes with built-in accessibility features:
- Keyboard navigation support
- Screen reader compatibility
- ARIA attributes for enhanced accessibility
- Focus management
We'll ensure our implementation maintains and enhances these accessibility features.
Introduction to Formik
Formik is a popular form management library for React that simplifies handling form state, validation, and submission. It works particularly well with MUI components, making it ideal for our category filter implementation.
Key Formik Features
- Form State Management: Tracks values, errors, touched fields, and submission state
- Validation: Supports synchronous and asynchronous validation
- Form Submission: Handles form submission with proper error handling
- Field Components: Provides components that connect to the form state
For our category filter, we'll leverage Formik's state management and validation capabilities to create a robust, user-friendly component.
Building the Category Filter: Step-by-Step Guide
Now let's build our custom category filter dropdown by combining MUI Select with Formik. We'll start with the basic setup and progressively enhance it with advanced features.
Setting Up the Project
First, let's set up a new React project and install the necessary dependencies.
npx create-react-app category-filter-app
cd category-filter-app
npm install @mui/material @emotion/react @emotion/styled formik yup
The above command creates a new React application and installs Material UI, Emotion (for styling), Formik for form management, and Yup for validation.
Creating the Basic Select Component
Let's start with a basic implementation of the MUI Select component to understand its core functionality.
import React, { useState } from 'react';
import {
FormControl,
InputLabel,
Select,
MenuItem,
Box
} from '@mui/material';
const BasicCategorySelect = () => {
const [category, setCategory] = useState('');
const handleChange = (event) => {
setCategory(event.target.value);
};
const categories = [
{ id: 'electronics', name: 'Electronics' },
{ id: 'clothing', name: 'Clothing' },
{ id: 'books', name: 'Books' },
{ id: 'home', name: 'Home & Kitchen' },
{ id: 'sports', name: 'Sports & Outdoors' }
];
return (
<Box sx={{ minWidth: 220 }}>
<FormControl fullWidth>
<InputLabel id="category-select-label">Category</InputLabel>
<Select
labelId="category-select-label"
id="category-select"
value={category}
label="Category"
onChange={handleChange}
>
{categories.map((category) => (
<MenuItem key={category.id} value={category.id}>
{category.name}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
);
};
export default BasicCategorySelect;
In this basic implementation, we've created a controlled Select component that:
- Maintains the selected category in local state
- Updates the state when a user selects a different category
- Renders a list of predefined categories as MenuItem components
The FormControl
component wraps our Select to provide proper layout and accessibility, while the InputLabel
provides a visible label that properly associates with the Select.
Integrating with Formik
Now, let's enhance our component by integrating it with Formik for better form management.
import React from 'react';
import {
FormControl,
InputLabel,
Select,
MenuItem,
Box,
FormHelperText
} from '@mui/material';
import { useFormik } from 'formik';
import * as Yup from 'yup';
const FormikCategorySelect = () => {
const categories = [
{ id: 'electronics', name: 'Electronics' },
{ id: 'clothing', name: 'Clothing' },
{ id: 'books', name: 'Books' },
{ id: 'home', name: 'Home & Kitchen' },
{ id: 'sports', name: 'Sports & Outdoors' }
];
const formik = useFormik({
initialValues: {
category: '',
},
validationSchema: Yup.object({
category: Yup.string().required('Please select a category'),
}),
onSubmit: (values) => {
console.log('Selected category:', values.category);
// Here you would typically filter your data or make an API call
},
});
return (
<Box sx={{ minWidth: 220 }}>
<form onSubmit={formik.handleSubmit}>
<FormControl
fullWidth
error={formik.touched.category && Boolean(formik.errors.category)} >
<InputLabel id="category-select-label">Category</InputLabel>
<Select
labelId="category-select-label"
id="category"
name="category"
value={formik.values.category}
label="Category"
onChange={formik.handleChange}
onBlur={formik.handleBlur}
>
{categories.map((category) => (
<MenuItem key={category.id} value={category.id}>
{category.name}
</MenuItem>
))}
</Select>
{formik.touched.category && formik.errors.category && (
<FormHelperText>{formik.errors.category}</FormHelperText>
)}
</FormControl>
<Box sx={{ mt: 2 }}>
<button type="submit">Apply Filter</button>
</Box>
</form>
</Box>
);
};
export default FormikCategorySelect;
In this enhanced version, we've:
- Replaced local state with Formik's form state management
- Added validation using Yup to ensure a category is selected
- Included error handling with FormHelperText to display validation errors
- Added a submit button to apply the filter
The integration with Formik gives us several advantages:
- Consistent form state management
- Built-in validation
- Easy integration with other form fields
- Form submission handling
Adding Multiple Selection Support
Now, let's enhance our component to support selecting multiple categories - a common requirement for filter interfaces.
import React from 'react';
import {
FormControl,
InputLabel,
Select,
MenuItem,
Box,
FormHelperText,
Chip,
OutlinedInput
} from '@mui/material';
import { useFormik } from 'formik';
import * as Yup from 'yup';
const MultiCategorySelect = () => {
const categories = [
{ id: 'electronics', name: 'Electronics' },
{ id: 'clothing', name: 'Clothing' },
{ id: 'books', name: 'Books' },
{ id: 'home', name: 'Home & Kitchen' },
{ id: 'sports', name: 'Sports & Outdoors' }
];
const formik = useFormik({
initialValues: {
categories: [],
},
validationSchema: Yup.object({
categories: Yup.array().min(1, 'Please select at least one category'),
}),
onSubmit: (values) => {
console.log('Selected categories:', values.categories);
// Here you would typically filter your data or make an API call
},
});
return (
<Box sx={{ minWidth: 220 }}>
<form onSubmit={formik.handleSubmit}>
<FormControl
fullWidth
error={formik.touched.categories && Boolean(formik.errors.categories)} >
<InputLabel id="category-select-label">Categories</InputLabel>
<Select
labelId="category-select-label"
id="categories"
name="categories"
multiple
value={formik.values.categories}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
input={<OutlinedInput label="Categories" />}
renderValue={(selected) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map((value) => {
const category = categories.find(cat => cat.id === value);
return (
<Chip
key={value}
label={category ? category.name : value}
/>
);
})}
</Box>
)} >
{categories.map((category) => (
<MenuItem key={category.id} value={category.id}>
{category.name}
</MenuItem>
))}
</Select>
{formik.touched.categories && formik.errors.categories && (
<FormHelperText>{formik.errors.categories}</FormHelperText>
)}
</FormControl>
<Box sx={{ mt: 2 }}>
<button type="submit">Apply Filters</button>
</Box>
</form>
</Box>
);
};
export default MultiCategorySelect;
In this multi-select version, we've:
- Changed the form field from a single value to an array of values
- Added the
multiple
prop to the Select component - Used
renderValue
to display selected categories as Chips - Updated the validation to require at least one category
- Used OutlinedInput to ensure the multi-select has the proper styling
The renderValue
prop is particularly powerful here, as it allows us to customize how selected values are displayed. By using Chip components, we create a modern, intuitive interface that clearly shows which categories are selected.
Creating a Complete Category Filter Component
Now, let's create a more complete and reusable category filter component with additional features:
import React from 'react';
import {
FormControl,
InputLabel,
Select,
MenuItem,
Box,
FormHelperText,
Chip,
OutlinedInput,
Typography,
Checkbox,
ListItemText,
Button,
Stack
} from '@mui/material';
import { useFormik } from 'formik';
import * as Yup from 'yup';
const ITEM_HEIGHT = 48;
const ITEM_PADDING_TOP = 8;
const MenuProps = {
PaperProps: {
style: {
maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP,
width: 250,
},
},
};
const CategoryFilter = ({
categories,
onFilterApply,
initialCategories = [],
label = "Categories",
showSelectAll = true,
showCheckboxes = true,
maxDisplayChips = 3
}) => {
const formik = useFormik({
initialValues: {
selectedCategories: initialCategories,
},
validationSchema: Yup.object({
selectedCategories: Yup.array().min(1, 'Please select at least one category'),
}),
onSubmit: (values) => {
onFilterApply(values.selectedCategories);
},
});
const handleSelectAll = () => {
const allCategoryIds = categories.map(cat => cat.id);
formik.setFieldValue('selectedCategories', allCategoryIds);
};
const handleClearAll = () => {
formik.setFieldValue('selectedCategories', []);
};
const isAllSelected = categories.length > 0 &&
formik.values.selectedCategories.length === categories.length;
return (
<Box sx={{ minWidth: 220 }}>
<form onSubmit={formik.handleSubmit}>
<FormControl
fullWidth
error={formik.touched.selectedCategories && Boolean(formik.errors.selectedCategories)}
variant="outlined" >
<InputLabel id="category-filter-label">{label}</InputLabel>
<Select
labelId="category-filter-label"
id="selectedCategories"
name="selectedCategories"
multiple
value={formik.values.selectedCategories}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
input={<OutlinedInput label={label} />}
renderValue={(selected) => {
if (selected.length === 0) {
return <Typography variant="body2" color="text.secondary">Select categories</Typography>;
}
if (selected.length > maxDisplayChips) {
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.slice(0, maxDisplayChips).map((value) => {
const category = categories.find(cat => cat.id === value);
return (
<Chip
key={value}
label={category ? category.name : value}
size="small"
/>
);
})}
<Chip
label={`+${selected.length - maxDisplayChips} more`}
size="small"
/>
</Box>
);
}
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map((value) => {
const category = categories.find(cat => cat.id === value);
return (
<Chip
key={value}
label={category ? category.name : value}
size="small"
/>
);
})}
</Box>
);
}}
MenuProps={MenuProps}
>
{showSelectAll && (
<MenuItem
dense
divider
onClick={isAllSelected ? handleClearAll : handleSelectAll}
>
<Checkbox
checked={isAllSelected}
indeterminate={formik.values.selectedCategories.length > 0 &&
!isAllSelected}
/>
<ListItemText
primary={isAllSelected ? "Deselect All" : "Select All"}
/>
</MenuItem>
)}
{categories.map((category) => (
<MenuItem key={category.id} value={category.id}>
{showCheckboxes && (
<Checkbox
checked={formik.values.selectedCategories.indexOf(category.id) > -1}
/>
)}
<ListItemText primary={category.name} />
</MenuItem>
))}
</Select>
{formik.touched.selectedCategories && formik.errors.selectedCategories && (
<FormHelperText>{formik.errors.selectedCategories}</FormHelperText>
)}
</FormControl>
<Stack direction="row" spacing={2} sx={{ mt: 2 }}>
<Button
type="button"
variant="outlined"
onClick={handleClearAll}
size="small"
>
Clear
</Button>
<Button
type="submit"
variant="contained"
size="small"
>
Apply Filter
</Button>
</Stack>
</form>
</Box>
);
};
export default CategoryFilter;
This enhanced component includes several advanced features:
- Select All/Deselect All - Allows users to quickly select or deselect all categories
- Checkboxes - Makes multi-selection more intuitive
- Chip Limiting - Shows a "+X more" chip when many categories are selected
- Configurable Props - Makes the component highly reusable with sensible defaults
- Improved Menu Sizing - Controls the dropdown height and width for better UX
- Clear/Apply Buttons - Provides explicit actions for filter application
Let's examine each enhancement:
Select All/Deselect All: This common pattern makes it easy for users to select all categories at once, or clear their selection entirely. The checkbox uses the indeterminate
state when some but not all categories are selected.
Checkboxes in MenuItems: Adding checkboxes to each MenuItem makes the multi-select nature of the component more obvious and provides a larger click target.
Chip Limiting: When many categories are selected, showing all chips can take up too much space. By limiting the displayed chips and adding a "+X more" indicator, we maintain a clean interface.
Menu Sizing: The MenuProps
object controls the dropdown's dimensions, preventing it from becoming too large on screens with many categories.
Using the Category Filter Component
Now let's see how to use our reusable component in a real application:
import React, { useState, useEffect } from 'react';
import { Box, Typography, Grid, Card, CardContent } from '@mui/material';
import CategoryFilter from './CategoryFilter';
const ProductFilterPage = () => {
const [products, setProducts] = useState([]);
const [filteredProducts, setFilteredProducts] = useState([]);
const [selectedCategories, setSelectedCategories] = useState([]);
const categories = [
{ id: 'electronics', name: 'Electronics' },
{ id: 'clothing', name: 'Clothing' },
{ id: 'books', name: 'Books' },
{ id: 'home', name: 'Home & Kitchen' },
{ id: 'sports', name: 'Sports & Outdoors' },
{ id: 'beauty', name: 'Beauty & Personal Care' },
{ id: 'toys', name: 'Toys & Games' },
{ id: 'automotive', name: 'Automotive' }
];
// Simulate fetching products from an API
useEffect(() => {
// In a real app, this would be an API call
const dummyProducts = [
{ id: 1, name: 'Smartphone', category: 'electronics', price: 699 },
{ id: 2, name: 'Laptop', category: 'electronics', price: 1299 },
{ id: 3, name: 'T-shirt', category: 'clothing', price: 25 },
{ id: 4, name: 'Jeans', category: 'clothing', price: 45 },
{ id: 5, name: 'Novel', category: 'books', price: 15 },
{ id: 6, name: 'Cookware Set', category: 'home', price: 89 },
{ id: 7, name: 'Basketball', category: 'sports', price: 30 },
{ id: 8, name: 'Shampoo', category: 'beauty', price: 12 },
{ id: 9, name: 'Board Game', category: 'toys', price: 35 },
{ id: 10, name: 'Car Vacuum', category: 'automotive', price: 50 }
];
setProducts(dummyProducts);
setFilteredProducts(dummyProducts);
}, []);
// Apply category filters
const handleFilterApply = (categories) => {
setSelectedCategories(categories);
if (categories.length === 0) {
setFilteredProducts(products);
} else {
const filtered = products.filter(product =>
categories.includes(product.category)
);
setFilteredProducts(filtered);
}
};
return (
<Box sx={{ padding: 3 }}>
<Typography variant="h4" gutterBottom>
Product Catalog
</Typography>
<Grid container spacing={3}>
<Grid item xs={12} md={3}>
<Box sx={{ mb: 3 }}>
<Typography variant="h6" gutterBottom>
Filters
</Typography>
<CategoryFilter
categories={categories}
onFilterApply={handleFilterApply}
initialCategories={selectedCategories}
label="Product Categories"
/>
</Box>
</Grid>
<Grid item xs={12} md={9}>
<Typography variant="h6" gutterBottom>
Products {selectedCategories.length > 0 && '(Filtered)'}
</Typography>
{filteredProducts.length === 0 ? (
<Typography>No products match the selected filters.</Typography>
) : (
<Grid container spacing={2}>
{filteredProducts.map(product => (
<Grid item xs={12} sm={6} md={4} key={product.id}>
<Card>
<CardContent>
<Typography variant="h6">{product.name}</Typography>
<Typography variant="body2" color="text.secondary">
Category: {product.category}
</Typography>
<Typography variant="body1">
${product.price}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
</Grid>
</Grid>
</Box>
);
};
export default ProductFilterPage;
In this implementation, we've created a complete product filtering page that:
- Displays a list of products
- Allows filtering by category using our custom component
- Updates the product list in real-time as filters are applied
- Handles the case when no products match the selected filters
This demonstrates how our CategoryFilter component integrates into a larger application, providing a seamless user experience.
Advanced Customization and Optimization
Let's explore some advanced customization options and performance optimizations for our category filter.
Custom Styling with Theme and sx Prop
We can enhance our component's appearance by using MUI's theming system and the sx
prop:
import React from 'react';
import {
FormControl,
InputLabel,
Select,
MenuItem,
Box,
FormHelperText,
Chip,
OutlinedInput,
Typography,
Checkbox,
ListItemText,
Button,
Stack,
useTheme
} from '@mui/material';
import { useFormik } from 'formik';
import * as Yup from 'yup';
const StyledCategoryFilter = ({
categories,
onFilterApply,
initialCategories = [],
label = "Categories"
}) => {
const theme = useTheme();
const formik = useFormik({
initialValues: {
selectedCategories: initialCategories,
},
validationSchema: Yup.object({
selectedCategories: Yup.array().min(1, 'Please select at least one category'),
}),
onSubmit: (values) => {
onFilterApply(values.selectedCategories);
},
});
const handleSelectAll = () => {
const allCategoryIds = categories.map(cat => cat.id);
formik.setFieldValue('selectedCategories', allCategoryIds);
};
const handleClearAll = () => {
formik.setFieldValue('selectedCategories', []);
};
// Custom menu props with theme-aware styling
const MenuProps = {
PaperProps: {
style: {
maxHeight: 48 * 4.5 + 8,
width: 250,
},
sx: {
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[4],
'& .MuiMenuItem-root': {
paddingY: 0.75,
'&:hover': {
backgroundColor: theme.palette.action.hover,
},
'&.Mui-selected': {
backgroundColor: `${theme.palette.primary.main}20`, // 20% opacity
'&:hover': {
backgroundColor: `${theme.palette.primary.main}30`, // 30% opacity
}
}
}
}
},
};
return (
<Box sx={{ minWidth: 220 }}>
<form onSubmit={formik.handleSubmit}>
<FormControl
fullWidth
error={formik.touched.selectedCategories && Boolean(formik.errors.selectedCategories)}
variant="outlined"
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 1.5,
transition: theme.transitions.create(['border-color', 'box-shadow']),
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: theme.palette.primary.light,
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderWidth: 2,
boxShadow: `0 0 0 3px ${theme.palette.primary.main}20`,
}
}
}} >
<InputLabel
id="styled-category-filter-label"
sx={{
fontWeight: 500,
'&.Mui-focused': {
color: theme.palette.primary.main,
}
}} >
{label}
</InputLabel>
<Select
labelId="styled-category-filter-label"
id="selectedCategories"
name="selectedCategories"
multiple
value={formik.values.selectedCategories}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
input={<OutlinedInput label={label} />}
renderValue={(selected) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map((value) => {
const category = categories.find(cat => cat.id === value);
return (
<Chip
key={value}
label={category ? category.name : value}
size="small"
sx={{
backgroundColor: `${theme.palette.primary.main}15`,
color: theme.palette.primary.dark,
fontWeight: 500,
'& .MuiChip-deleteIcon': {
color: theme.palette.primary.main,
'&:hover': {
color: theme.palette.primary.dark,
}
}
}}
/>
);
})}
</Box>
)}
MenuProps={MenuProps} >
<MenuItem
dense
divider
onClick={formik.values.selectedCategories.length === categories.length
? handleClearAll
: handleSelectAll}
sx={{
borderBottom: `1px solid ${theme.palette.divider}`,
fontWeight: 500
}} >
<Checkbox
checked={formik.values.selectedCategories.length === categories.length}
indeterminate={formik.values.selectedCategories.length > 0 &&
formik.values.selectedCategories.length < categories.length}
color="primary"
/>
<ListItemText
primary={formik.values.selectedCategories.length === categories.length
? "Deselect All"
: "Select All"}
/>
</MenuItem>
{categories.map((category) => (
<MenuItem key={category.id} value={category.id}>
<Checkbox
checked={formik.values.selectedCategories.indexOf(category.id) > -1}
color="primary"
/>
<ListItemText primary={category.name} />
</MenuItem>
))}
</Select>
{formik.touched.selectedCategories && formik.errors.selectedCategories && (
<FormHelperText sx={{ marginLeft: 1.5 }}>
{formik.errors.selectedCategories}
</FormHelperText>
)}
</FormControl>
<Stack direction="row" spacing={2} sx={{ mt: 2 }}>
<Button
type="button"
variant="outlined"
onClick={handleClearAll}
size="small"
sx={{
borderRadius: 1.5,
textTransform: 'none',
fontWeight: 600
}}
>
Clear
</Button>
<Button
type="submit"
variant="contained"
size="small"
sx={{
borderRadius: 1.5,
textTransform: 'none',
fontWeight: 600,
boxShadow: theme.shadows[2],
'&:hover': {
boxShadow: theme.shadows[4],
}
}}
>
Apply Filter
</Button>
</Stack>
</form>
</Box>
);
};
export default StyledCategoryFilter;
In this styled version, we've:
- Used the theme object to access design tokens for consistent styling
- Enhanced the Select dropdown with custom hover and focus states
- Styled the Chips to use theme colors with reduced opacity for a subtle look
- Added custom styles to MenuItems for better visual hierarchy
- Styled the buttons with rounded corners and removed text transformation
- Added subtle shadows and transitions for a more polished feel
These styling enhancements make the component feel more refined while maintaining the Material Design language.
Optimizing for Large Datasets with Virtualization
When dealing with a large number of categories, rendering all MenuItems at once can cause performance issues. Let's optimize our component using virtualization:
import React from 'react';
import {
FormControl,
InputLabel,
Select,
MenuItem,
Box,
FormHelperText,
Chip,
OutlinedInput,
Typography,
Checkbox,
ListItemText,
Button,
Stack
} from '@mui/material';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { FixedSizeList } from 'react-window';
// You'll need to install react-window:
// npm install react-window
const ITEM_HEIGHT = 48;
const ITEM_PADDING_TOP = 8;
const VirtualizedCategoryFilter = ({
categories,
onFilterApply,
initialCategories = []
}) => {
const formik = useFormik({
initialValues: {
selectedCategories: initialCategories,
},
validationSchema: Yup.object({
selectedCategories: Yup.array().min(1, 'Please select at least one category'),
}),
onSubmit: (values) => {
onFilterApply(values.selectedCategories);
},
});
// Virtualized list renderer
const renderVirtualizedList = (props) => {
const { data, index, style } = props;
const category = data[index];
return (
<MenuItem
style={style}
key={category.id}
value={category.id}
selected={formik.values.selectedCategories.indexOf(category.id) > -1}
onClick={() => {
const currentIndex = formik.values.selectedCategories.indexOf(category.id);
const newSelected = [...formik.values.selectedCategories];
if (currentIndex === -1) {
newSelected.push(category.id);
} else {
newSelected.splice(currentIndex, 1);
}
formik.setFieldValue('selectedCategories', newSelected);
}}
>
<Checkbox
checked={formik.values.selectedCategories.indexOf(category.id) > -1}
/>
<ListItemText primary={category.name} />
</MenuItem>
);
};
// Custom menu component using virtualization
const MenuListComponent = React.useCallback(
(props) => {
const { children, ...other } = props;
// The first child is the "Select All" option which we handle separately
const selectAllOption = children[0];
const categoryOptions = categories; // Use our categories array
return (
<div {...other}>
{selectAllOption}
<FixedSizeList
height={ITEM_HEIGHT * 8}
width="100%"
itemSize={ITEM_HEIGHT}
itemCount={categoryOptions.length}
overscanCount={5}
itemData={categoryOptions}
>
{renderVirtualizedList}
</FixedSizeList>
</div>
);
},
[categories, formik.values.selectedCategories]
);
const handleSelectAll = () => {
const allCategoryIds = categories.map(cat => cat.id);
formik.setFieldValue('selectedCategories', allCategoryIds);
};
const handleClearAll = () => {
formik.setFieldValue('selectedCategories', []);
};
// Custom menu props with virtualized list
const MenuProps = {
PaperProps: {
style: {
maxHeight: ITEM_HEIGHT * 8 + ITEM_PADDING_TOP,
width: 250,
},
},
MenuListProps: {
component: MenuListComponent,
},
};
return (
<Box sx={{ minWidth: 220 }}>
<form onSubmit={formik.handleSubmit}>
<FormControl
fullWidth
error={formik.touched.selectedCategories && Boolean(formik.errors.selectedCategories)}
variant="outlined" >
<InputLabel id="virtualized-category-filter-label">Categories</InputLabel>
<Select
labelId="virtualized-category-filter-label"
id="selectedCategories"
name="selectedCategories"
multiple
value={formik.values.selectedCategories}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
input={<OutlinedInput label="Categories" />}
renderValue={(selected) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map((value) => {
const category = categories.find(cat => cat.id === value);
return (
<Chip
key={value}
label={category ? category.name : value}
size="small"
/>
);
})}
</Box>
)}
MenuProps={MenuProps} >
<MenuItem
dense
divider
onClick={formik.values.selectedCategories.length === categories.length
? handleClearAll
: handleSelectAll} >
<Checkbox
checked={formik.values.selectedCategories.length === categories.length}
indeterminate={formik.values.selectedCategories.length > 0 &&
formik.values.selectedCategories.length < categories.length}
/>
<ListItemText
primary={formik.values.selectedCategories.length === categories.length
? "Deselect All"
: "Select All"}
/>
</MenuItem>
{/* The actual items will be rendered by the virtualized list */}
</Select>
{formik.touched.selectedCategories && formik.errors.selectedCategories && (
<FormHelperText>{formik.errors.selectedCategories}</FormHelperText>
)}
</FormControl>
<Stack direction="row" spacing={2} sx={{ mt: 2 }}>
<Button
type="button"
variant="outlined"
onClick={handleClearAll}
size="small"
>
Clear
</Button>
<Button
type="submit"
variant="contained"
size="small"
>
Apply Filter
</Button>
</Stack>
</form>
</Box>
);
};
export default VirtualizedCategoryFilter;
In this virtualized version, we've:
- Used
react-window
'sFixedSizeList
component to render only the visible MenuItems - Created a custom
MenuListComponent
that integrates the virtualized list - Maintained the "Select All" option outside the virtualized list
- Optimized the click handler for better performance
This approach significantly improves performance when dealing with hundreds or thousands of categories, as it only renders the items currently visible in the viewport.
Accessibility Enhancements
Let's enhance our component's accessibility to ensure it works well for all users:
import React from 'react';
import {
FormControl,
InputLabel,
Select,
MenuItem,
Box,
FormHelperText,
Chip,
OutlinedInput,
Typography,
Checkbox,
ListItemText,
Button,
Stack,
Tooltip
} from '@mui/material';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import FilterListIcon from '@mui/icons-material/FilterList';
import ClearIcon from '@mui/icons-material/Clear';
const AccessibleCategoryFilter = ({
categories,
onFilterApply,
initialCategories = [],
label = "Categories",
id = "category-filter"
}) => {
const formik = useFormik({
initialValues: {
selectedCategories: initialCategories,
},
validationSchema: Yup.object({
selectedCategories: Yup.array().min(1, 'Please select at least one category'),
}),
onSubmit: (values) => {
onFilterApply(values.selectedCategories);
},
});
const handleSelectAll = () => {
const allCategoryIds = categories.map(cat => cat.id);
formik.setFieldValue('selectedCategories', allCategoryIds);
};
const handleClearAll = () => {
formik.setFieldValue('selectedCategories', []);
};
const MenuProps = {
PaperProps: {
style: {
maxHeight: 48 * 4.5 + 8,
width: 250,
},
},
// Improve keyboard navigation
disableAutoFocusItem: false,
// Ensure proper ARIA attributes
getContentAnchorEl: null,
anchorOrigin: {
vertical: 'bottom',
horizontal: 'left',
},
};
// Get selected category names for screen readers
const getSelectedCategoryNames = () => {
return formik.values.selectedCategories
.map(id => {
const category = categories.find(cat => cat.id === id);
return category ? category.name : id;
})
.join(', ');
};
const selectedCount = formik.values.selectedCategories.length;
const totalCount = categories.length;
return (
<Box sx={{ minWidth: 220 }}>
<form onSubmit={formik.handleSubmit}>
{/* Add a visually hidden but screen reader accessible label */}
<Typography
id={`${id}-instructions`}
sx={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)', m: -1 }} >
Select one or more categories to filter the content. Use space bar to select or deselect an item.
</Typography>
<FormControl
fullWidth
error={formik.touched.selectedCategories && Boolean(formik.errors.selectedCategories)}
variant="outlined"
>
<InputLabel id={`${id}-label`}>{label}</InputLabel>
<Select
labelId={`${id}-label`}
id={id}
name="selectedCategories"
multiple
value={formik.values.selectedCategories}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
input={<OutlinedInput label={label} />}
renderValue={(selected) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.length === 0 ? (
<Typography variant="body2" color="text.secondary">
Select categories
</Typography>
) : (
selected.map((value) => {
const category = categories.find(cat => cat.id === value);
return (
<Chip
key={value}
label={category ? category.name : value}
size="small"
deleteIcon={<ClearIcon fontSize="small" />}
onDelete={() => {
const newSelected = formik.values.selectedCategories.filter(
id => id !== value
);
formik.setFieldValue('selectedCategories', newSelected);
}}
// Improve accessibility
aria-label={`Remove ${category ? category.name : value}`}
/>
);
})
)}
</Box>
)}
MenuProps={MenuProps}
// Connect to instructions for screen readers
aria-describedby={`${id}-instructions`}
// Provide count information for screen readers
aria-label={selectedCount > 0
? `${label}: ${selectedCount} of ${totalCount} selected`
: label}
>
<MenuItem
dense
divider
onClick={selectedCount === totalCount ? handleClearAll : handleSelectAll}
aria-label={selectedCount === totalCount ? "Deselect all categories" : "Select all categories"}
>
<Checkbox
checked={selectedCount === totalCount}
indeterminate={selectedCount > 0 && selectedCount < totalCount}
inputProps={{
'aria-label': selectedCount === totalCount
? "All categories are selected"
: "Not all categories are selected"
}}
/>
<ListItemText
primary={selectedCount === totalCount ? "Deselect All" : "Select All"}
/>
</MenuItem>
{categories.map((category) => (
<MenuItem
key={category.id}
value={category.id}
aria-label={formik.values.selectedCategories.includes(category.id)
? `${category.name} category selected`
: `${category.name} category not selected`}
>
<Checkbox
checked={formik.values.selectedCategories.indexOf(category.id) > -1}
inputProps={{
'aria-label': `Toggle ${category.name} category`
}}
/>
<ListItemText primary={category.name} />
</MenuItem>
))}
</Select>
{formik.touched.selectedCategories && formik.errors.selectedCategories && (
<FormHelperText id={`${id}-error`}>
{formik.errors.selectedCategories}
</FormHelperText>
)}
</FormControl>
<Stack direction="row" spacing={2} sx={{ mt: 2 }}>
<Tooltip title="Clear all filters">
<Button
type="button"
variant="outlined"
onClick={handleClearAll}
size="small"
startIcon={<ClearIcon />}
aria-label="Clear all category filters"
>
Clear
</Button>
</Tooltip>
<Tooltip title="Apply selected filters">
<Button
type="submit"
variant="contained"
size="small"
startIcon={<FilterListIcon />}
aria-label={selectedCount > 0
? `Apply filter with ${selectedCount} categories selected: ${getSelectedCategoryNames()}`
: "Apply filter with no categories selected"}
>
Apply Filter
</Button>
</Tooltip>
</Stack>
</form>
</Box>
);
};
export default AccessibleCategoryFilter;
In this accessibility-enhanced version, we've:
- Added proper ARIA labels and descriptions for screen readers
- Included hidden instructions for keyboard users
- Made Chips deletable with accessible delete buttons
- Added tooltips to buttons for additional context
- Improved keyboard navigation in the dropdown menu
- Added status information about selected counts
- Enhanced focus management
These accessibility enhancements ensure that our component is usable by everyone, including people using screen readers, keyboard-only navigation, or other assistive technologies.
Best Practices and Common Issues
Let's explore some best practices and common issues when implementing category filters with MUI Select and Formik.
Best Practices
-
Controlled vs Uncontrolled: Always use controlled components with Formik for predictable behavior and easy integration with form validation.
-
Error Handling: Display clear error messages when validation fails, and ensure they're accessible to screen readers.
-
Performance Optimization: For large datasets, use virtualization or pagination to prevent performance issues.
-
Accessibility: Always include proper labels, ARIA attributes, and keyboard navigation support.
-
Visual Feedback: Provide clear visual feedback for selected items, hover states, and focus states.
-
State Persistence: Consider persisting filter selections across page refreshes using localStorage or URL parameters.
-
Mobile Optimization: Ensure the component works well on mobile devices, with appropriate touch targets and responsive design.
Common Issues and Solutions
-
Issue: Select dropdown is cut off at the bottom of the screen. Solution: Use the
anchorOrigin
andtransformOrigin
props inMenuProps
to control the dropdown position. -
Issue: Poor performance with large datasets. Solution: Implement virtualization with
react-window
or limit the number of visible options. -
Issue: Difficulty styling the dropdown menu. Solution: Use the
PaperProps
andMenuProps
to apply custom styles to the dropdown. -
Issue: Form validation errors not appearing. Solution: Ensure the
error
prop is properly set based on Formik's touched and errors states. -
Issue: Multiple select chips overflow the input. Solution: Implement a "more" chip or a counter to indicate additional selections.
-
Issue: Keyboard navigation issues in the dropdown. Solution: Ensure proper tab order and focus management, and test thoroughly with keyboard-only navigation.
Wrapping Up
In this comprehensive guide, we've built a robust category filter dropdown using React MUI Select and Formik. We've covered everything from basic implementation to advanced customization, performance optimization, and accessibility enhancements.
The combination of MUI's powerful Select component and Formik's form management capabilities provides a solid foundation for building sophisticated filtering interfaces. By following the patterns and practices outlined in this guide, you can create intuitive, accessible, and performant filter components that enhance the user experience in your React applications.
Remember to adapt these patterns to your specific use case, and always prioritize accessibility and performance to ensure your components work well for all users, regardless of their abilities or device constraints.