Menu

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:

  1. Implement a fully functional category filter using MUI Select and Formik
  2. Configure controlled vs uncontrolled Select components for different use cases
  3. Properly handle form state and validation with Formik integration
  4. Customize the Select component's appearance using MUI's styling options
  5. Implement accessibility features for inclusive user experiences
  6. 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:

  1. Filled - Has a solid background and bottom border
  2. Outlined - Has a visible outline around the entire component
  3. 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

PropTypeDefaultDescription
valueany-The selected value(s). Required for controlled components.
onChangefunction-Callback fired when a menu item is selected.
multiplebooleanfalseIf true, multiple values can be selected.
displayEmptybooleanfalse

If true, the selected item is displayed even if its value is empty.

renderValuefunction-Function to customize the display value.
MenuPropsobject-Props applied to the Menu component.
variant'standard' | 'outlined' | 'filled''standard'The variant to use.
labelnode-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:

  1. Styling with the sx prop: For direct, component-specific styling
  2. Theme customization: For app-wide consistent styling
  3. 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

  1. Form State Management: Tracks values, errors, touched fields, and submission state
  2. Validation: Supports synchronous and asynchronous validation
  3. Form Submission: Handles form submission with proper error handling
  4. 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:

  1. Maintains the selected category in local state
  2. Updates the state when a user selects a different category
  3. 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:

  1. Replaced local state with Formik's form state management
  2. Added validation using Yup to ensure a category is selected
  3. Included error handling with FormHelperText to display validation errors
  4. 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:

  1. Changed the form field from a single value to an array of values
  2. Added the multiple prop to the Select component
  3. Used renderValue to display selected categories as Chips
  4. Updated the validation to require at least one category
  5. 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:

  1. Select All/Deselect All - Allows users to quickly select or deselect all categories
  2. Checkboxes - Makes multi-selection more intuitive
  3. Chip Limiting - Shows a "+X more" chip when many categories are selected
  4. Configurable Props - Makes the component highly reusable with sensible defaults
  5. Improved Menu Sizing - Controls the dropdown height and width for better UX
  6. 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:

  1. Displays a list of products
  2. Allows filtering by category using our custom component
  3. Updates the product list in real-time as filters are applied
  4. 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:

  1. Used the theme object to access design tokens for consistent styling
  2. Enhanced the Select dropdown with custom hover and focus states
  3. Styled the Chips to use theme colors with reduced opacity for a subtle look
  4. Added custom styles to MenuItems for better visual hierarchy
  5. Styled the buttons with rounded corners and removed text transformation
  6. 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:

  1. Used react-window's FixedSizeList component to render only the visible MenuItems
  2. Created a custom MenuListComponent that integrates the virtualized list
  3. Maintained the "Select All" option outside the virtualized list
  4. 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:

  1. Added proper ARIA labels and descriptions for screen readers
  2. Included hidden instructions for keyboard users
  3. Made Chips deletable with accessible delete buttons
  4. Added tooltips to buttons for additional context
  5. Improved keyboard navigation in the dropdown menu
  6. Added status information about selected counts
  7. 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

  1. Controlled vs Uncontrolled: Always use controlled components with Formik for predictable behavior and easy integration with form validation.

  2. Error Handling: Display clear error messages when validation fails, and ensure they're accessible to screen readers.

  3. Performance Optimization: For large datasets, use virtualization or pagination to prevent performance issues.

  4. Accessibility: Always include proper labels, ARIA attributes, and keyboard navigation support.

  5. Visual Feedback: Provide clear visual feedback for selected items, hover states, and focus states.

  6. State Persistence: Consider persisting filter selections across page refreshes using localStorage or URL parameters.

  7. Mobile Optimization: Ensure the component works well on mobile devices, with appropriate touch targets and responsive design.

Common Issues and Solutions

  1. Issue: Select dropdown is cut off at the bottom of the screen. Solution: Use the anchorOrigin and transformOrigin props in MenuProps to control the dropdown position.

  2. Issue: Poor performance with large datasets. Solution: Implement virtualization with react-window or limit the number of visible options.

  3. Issue: Difficulty styling the dropdown menu. Solution: Use the PaperProps and MenuProps to apply custom styles to the dropdown.

  4. Issue: Form validation errors not appearing. Solution: Ensure the error prop is properly set based on Formik's touched and errors states.

  5. Issue: Multiple select chips overflow the input. Solution: Implement a "more" chip or a counter to indicate additional selections.

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