Building a Tag Input Field with React MUI Chip and Autocomplete
As a front-end developer, creating intuitive and functional input fields is a common requirement. One particularly useful pattern is the tag input field - allowing users to add multiple selections as tags. Material UI (MUI) provides powerful components like Chip and Autocomplete that can be combined to create a robust tag input solution.
In this guide, I'll walk you through building a complete tag input field with React MUI that includes autocomplete functionality, custom styling, validation, and advanced features. We'll explore both basic implementations and more sophisticated solutions that you can adapt for your projects.
What You'll Learn
By the end of this tutorial, you'll be able to:
- Understand MUI Chip component fundamentals and its key props
- Implement a basic tag input field using MUI Chip and Autocomplete
- Create controlled and uncontrolled tag input components
- Add validation, custom styling, and accessibility features
- Handle complex use cases like async data loading and form integration
- Implement performance optimizations for large datasets
Understanding MUI Chip Component
The MUI Chip component is a versatile UI element that represents compact entities like tags, filters, or options. Before we combine it with Autocomplete, let's understand what makes Chip so useful for tag inputs.
Core Chip Properties and Variants
Chips in MUI are compact elements that represent attributes, inputs, or actions. They're perfect for tag inputs because they provide built-in functionality for displaying selected values with options to delete them.
The Chip component offers several variants that change its appearance and behavior:
Variant | Description | Use Case |
---|---|---|
filled (default) | Solid background with contrasting text | Standard tags, selected filters |
outlined | Transparent with outlined border | More subtle tag representations |
You can also control the Chip's size using the size
prop, which accepts small
or medium
(default).
Essential Chip Props
Chips come with several key props that make them ideal for tag inputs:
Prop | Type | Description |
---|---|---|
label | node | The content displayed on the chip |
onDelete | function | Callback fired when the delete icon is clicked |
deleteIcon | node | Custom delete icon element |
color | string | Color of the chip (default, primary, secondary, etc.) |
disabled | boolean | Disables interaction with the chip |
icon | node | Icon element displayed before the label |
clickable | boolean | Makes the chip clickable |
onClick | function | Callback fired when the chip is clicked |
Let's look at a basic Chip example to understand its usage:
import { Chip, Stack } from '@mui/material';
import FaceIcon from '@mui/icons-material/Face';
function BasicChipDemo() {
const handleDelete = () => {
console.log('Chip deleted');
};
return (
<Stack direction="row" spacing={1}>
<Chip label="Basic Chip" />
<Chip
label="Deletable Chip"
onDelete={handleDelete}
/>
<Chip
icon={<FaceIcon />}
label="With Icon"
variant="outlined"
color="primary"
/>
<Chip
label="Clickable Chip"
clickable
onClick={() => console.log('Chip clicked')}
/>
</Stack>
);
}
Customizing Chip Appearance
You can customize Chips using MUI's sx
prop or theme overrides. The sx
prop provides a shorthand way to apply styles directly to components:
<Chip
label="Custom Chip"
sx={{
backgroundColor: '#f5f5f5',
borderRadius: '4px',
'& .MuiChip-label': {
fontWeight: 'bold',
},
'&:hover': {
backgroundColor: '#e0e0e0',
},
}}
/>
For more consistent styling across your application, you can customize Chips through theme overrides:
import { createTheme, ThemeProvider } from '@mui/material/styles';
const theme = createTheme({
components: {
MuiChip: {
styleOverrides: {
root: {
borderRadius: '4px',
backgroundColor: '#f8f9fa',
},
label: {
fontWeight: 500,
},
deleteIcon: {
color: '#666',
'&:hover': {
color: '#333',
},
},
},
variants: [
{
props: { variant: 'tag' },
style: {
backgroundColor: '#e3f2fd',
color: '#1976d2',
borderRadius: '16px',
},
},
],
},
},
});
function ThemedChips() {
return (
<ThemeProvider theme={theme}>
<Chip label="Themed Chip" onDelete={() => {}} />
<Chip label="Custom Variant" variant="tag" />
</ThemeProvider>
);
}
Understanding MUI Autocomplete
The Autocomplete component is MUI's solution for combobox inputs that provide suggestions as you type. When paired with Chip, it becomes a powerful tag input field.
Key Autocomplete Props
Prop | Type | Description |
---|---|---|
multiple | boolean | Allows multiple selections, rendering them as chips |
options | array | Array of available options |
value | any | Current value (controlled component) |
defaultValue | any | Default value (uncontrolled component) |
onChange | function | Callback fired when the value changes |
getOptionLabel | function | Function that defines how to display options |
renderTags | function | Function to customize how tags are rendered |
freeSolo | boolean | Allows values not in the options list |
filterOptions | function | Custom filter function for options |
loading | boolean | Indicates if options are being loaded |
disableClearable | boolean | Removes the clear button |
Building a Basic Tag Input Field
Now let's combine Chip and Autocomplete to create a basic tag input field. We'll start with a simple implementation and gradually add more features.
Setting Up the Project
First, let's set up a new React project and install the necessary dependencies:
// Using npm
npm install @mui/material @mui/icons-material @emotion/react @emotion/styled
// Using yarn
yarn add @mui/material @mui/icons-material @emotion/react @emotion/styled
Creating a Simple Tag Input Component
Let's build a basic tag input field that allows users to select multiple options from a predefined list:
import React, { useState } from 'react';
import { Autocomplete, TextField, Chip } from '@mui/material';
const SimpleTagInput = () => {
// Sample options for our tag input
const tagOptions = [
'React', 'JavaScript', 'TypeScript', 'HTML', 'CSS',
'Node.js', 'Express', 'MongoDB', 'Redux', 'Next.js'
];
// State to track selected tags
const [selectedTags, setSelectedTags] = useState([]);
const handleChange = (event, newValue) => {
setSelectedTags(newValue);
};
return (
<Autocomplete
multiple
options={tagOptions}
value={selectedTags}
onChange={handleChange}
renderInput={(params) => (
<TextField
{...params}
variant="outlined"
label="Select Tags"
placeholder="Tags"
/>
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip
label={option}
{...getTagProps({ index })}
color="primary"
variant="outlined"
/>
))
}
/>
);
};
export default SimpleTagInput;
In this example:
- We use the
multiple
prop on Autocomplete to allow selecting multiple options - We maintain selected tags in state with
useState
- The
renderTags
prop customizes how selected options appear as Chips - The
getTagProps
function provides necessary props for each Chip, including delete functionality
Handling Tag Selection and Removal
The Autocomplete component handles most of the tag selection and removal logic for us:
- When a user selects an option from the dropdown, it's added to the
value
array - When a user clicks the delete icon on a Chip, the option is removed from the array
- The
onChange
handler is called with the updated array, which we use to update our state
Enhancing the Tag Input with Custom Styling
Let's improve our tag input with custom styling to make it more visually appealing and match your application's design:
import React, { useState } from 'react';
import { Autocomplete, TextField, Chip, Box } from '@mui/material';
import { styled } from '@mui/material/styles';
// Custom styled Chip component
const CustomChip = styled(Chip)(({ theme }) => ({
borderRadius: '6px',
backgroundColor: theme.palette.primary.light,
color: theme.palette.primary.contrastText,
fontWeight: 500,
'&:hover': {
backgroundColor: theme.palette.primary.main,
},
'& .MuiChip-deleteIcon': {
color: theme.palette.primary.contrastText,
'&:hover': {
color: '#fff',
},
},
}));
// Custom styled Autocomplete
const StyledAutocomplete = styled(Autocomplete)(({ theme }) => ({
'& .MuiAutocomplete-tag': {
margin: '2px',
},
'& .MuiInputBase-root': {
padding: '4px 8px',
},
}));
const StyledTagInput = () => {
const tagOptions = [
'React', 'JavaScript', 'TypeScript', 'HTML', 'CSS',
'Node.js', 'Express', 'MongoDB', 'Redux', 'Next.js'
];
const [selectedTags, setSelectedTags] = useState([]);
return (
<Box sx={{ width: '100%', maxWidth: 500 }}>
<StyledAutocomplete
multiple
options={tagOptions}
value={selectedTags}
onChange={(event, newValue) => setSelectedTags(newValue)}
renderInput={(params) => (
<TextField
{...params}
variant="outlined"
label="Select Tags"
placeholder={selectedTags.length > 0 ? '' : 'Add tags...'}
InputProps={{
...params.InputProps,
sx: { paddingY: '8px' }
}}
/>
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<CustomChip
label={option}
{...getTagProps({ index })}
/>
))
}
/>
</Box>
);
};
export default StyledTagInput;
In this enhanced version:
- We've created a custom styled Chip component with a different shape and colors
- We've adjusted the Autocomplete's styling to improve the spacing of tags
- We've added conditional placeholder text that only shows when no tags are selected
- We've wrapped the component in a Box with width constraints
Building a Tag Input with Free Text Entry
Often, you'll want to allow users to enter custom tags that aren't in the predefined list. The freeSolo
prop enables this functionality:
import React, { useState } from 'react';
import { Autocomplete, TextField, Chip, Box } from '@mui/material';
const FreeTextTagInput = () => {
const suggestedTags = [
'React', 'JavaScript', 'TypeScript', 'HTML', 'CSS',
'Node.js', 'Express', 'MongoDB', 'Redux', 'Next.js'
];
const [selectedTags, setSelectedTags] = useState([]);
const handleChange = (event, newValue) => {
// Filter out any empty strings that might be entered
const filteredValues = newValue.filter(tag => tag.trim() !== '');
setSelectedTags(filteredValues);
};
const handleKeyDown = (event) => {
// Add a new tag when the user presses Enter or comma
if (['Enter', ','].includes(event.key) && event.target.value.trim() !== '') {
event.preventDefault();
const newTag = event.target.value.trim();
if (!selectedTags.includes(newTag)) {
setSelectedTags([...selectedTags, newTag]);
// Clear the input field
event.target.value = '';
}
}
};
return (
<Box sx={{ width: '100%', maxWidth: 500 }}>
<Autocomplete
multiple
freeSolo
options={suggestedTags}
value={selectedTags}
onChange={handleChange}
renderInput={(params) => (
<TextField
{...params}
variant="outlined"
label="Tags"
placeholder="Add tags..."
onKeyDown={handleKeyDown}
/>
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip
label={option}
{...getTagProps({ index })}
color="primary"
/>
))
}
/>
</Box>
);
};
export default FreeTextTagInput;
Key features of this implementation:
- The
freeSolo
prop allows entering values not in the options list - We added a
handleKeyDown
function to support creating tags by pressing Enter or comma - We filter out empty strings to prevent invalid tags
- We check for duplicates before adding new tags
Advanced: Tag Input with Validation
Let's build a more sophisticated tag input that includes validation for the entered tags:
import React, { useState } from 'react';
import {
Autocomplete,
TextField,
Chip,
Box,
Typography
} from '@mui/material';
import { styled } from '@mui/material/styles';
// Custom styled error message
const ErrorMessage = styled(Typography)(({ theme }) => ({
color: theme.palette.error.main,
fontSize: '0.75rem',
marginTop: theme.spacing(0.5),
marginLeft: theme.spacing(1.5),
}));
const ValidatedTagInput = () => {
const suggestedTags = [
'React', 'JavaScript', 'TypeScript', 'HTML', 'CSS',
'Node.js', 'Express', 'MongoDB', 'Redux', 'Next.js'
];
const [selectedTags, setSelectedTags] = useState([]);
const [inputValue, setInputValue] = useState('');
const [error, setError] = useState('');
// Validation rules
const validateTag = (tag) => {
if (tag.length < 2) {
return 'Tag must be at least 2 characters long';
}
if (tag.length > 20) {
return 'Tag cannot exceed 20 characters';
}
if (!/^[a-zA-Z0-9.#+-_ ]+$/.test(tag)) {
return 'Tag can only contain letters, numbers, and basic punctuation';
}
if (selectedTags.includes(tag)) {
return 'This tag has already been added';
}
return '';
};
const handleInputChange = (event, newInputValue) => {
setInputValue(newInputValue);
// Clear error when input changes
if (error) setError('');
};
const handleChange = (event, newValue) => {
// Handle deletion (which is always valid)
if (newValue.length < selectedTags.length) {
setSelectedTags(newValue);
return;
}
// Handle addition - get the new tag
const newTag = newValue[newValue.length - 1];
// Validate the new tag
const validationError = validateTag(newTag);
if (validationError) {
setError(validationError);
return;
}
setSelectedTags(newValue);
};
const handleKeyDown = (event) => {
if (event.key === 'Enter' && inputValue) {
event.preventDefault();
// Validate the tag
const validationError = validateTag(inputValue);
if (validationError) {
setError(validationError);
return;
}
// Add the tag if it's valid
if (!selectedTags.includes(inputValue)) {
setSelectedTags([...selectedTags, inputValue]);
setInputValue('');
}
}
};
return (
<Box sx={{ width: '100%', maxWidth: 500 }}>
<Autocomplete
multiple
freeSolo
options={suggestedTags.filter(option => !selectedTags.includes(option))}
value={selectedTags}
inputValue={inputValue}
onInputChange={handleInputChange}
onChange={handleChange}
renderInput={(params) => (
<TextField
{...params}
variant="outlined"
label="Tags"
placeholder="Add tags..."
onKeyDown={handleKeyDown}
error={!!error}
/>
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip
label={option}
{...getTagProps({ index })}
color="primary"
/>
))
}
/>
{error && <ErrorMessage>{error}</ErrorMessage>}
</Box>
);
};
export default ValidatedTagInput;
This implementation includes:
- Tag validation with specific rules (length, characters, duplicates)
- Error messages that display below the input
- Controlled input value for better validation handling
- Filtering of already selected tags from the suggestions
Integrating with Form Libraries
In real-world applications, tag inputs are often part of larger forms. Let's see how to integrate our tag input with a popular form library like Formik:
import React from 'react';
import { Formik, Form, Field } from 'formik';
import * as Yup from 'yup';
import {
Autocomplete,
TextField,
Chip,
Box,
Button,
Typography
} from '@mui/material';
// Define validation schema
const FormSchema = Yup.object().shape({
title: Yup.string()
.required('Title is required')
.min(5, 'Title must be at least 5 characters'),
tags: Yup.array()
.min(1, 'At least one tag is required')
.max(5, 'Maximum 5 tags allowed')
});
const FormIntegratedTagInput = () => {
const suggestedTags = [
'React', 'JavaScript', 'TypeScript', 'HTML', 'CSS',
'Node.js', 'Express', 'MongoDB', 'Redux', 'Next.js'
];
return (
<Box sx={{ maxWidth: 600, mx: 'auto', p: 2 }}>
<Typography variant="h5" gutterBottom>
Create New Article
</Typography>
<Formik
initialValues={{
title: '',
tags: []
}}
validationSchema={FormSchema}
onSubmit={(values, { setSubmitting }) => {
// In a real app, you would submit to an API
console.log('Form values:', values);
alert(JSON.stringify(values, null, 2));
setSubmitting(false);
}}
>
{({
values,
errors,
touched,
handleChange,
handleBlur,
setFieldValue,
isSubmitting
}) => (
<Form>
<Box sx={{ mb: 3 }}>
<TextField
fullWidth
name="title"
label="Article Title"
value={values.title}
onChange={handleChange}
onBlur={handleBlur}
error={touched.title && Boolean(errors.title)}
helperText={touched.title && errors.title}
margin="normal"
/>
</Box>
<Box sx={{ mb: 3 }}>
<Autocomplete
multiple
freeSolo
options={suggestedTags}
value={values.tags}
onChange={(event, newValue) => {
setFieldValue('tags', newValue);
}}
renderInput={(params) => (
<TextField
{...params}
label="Tags"
placeholder="Add tags..."
error={touched.tags && Boolean(errors.tags)}
helperText={touched.tags && errors.tags}
/>
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip
label={option}
{...getTagProps({ index })}
color="primary"
/>
))
}
/>
</Box>
<Button
type="submit"
variant="contained"
color="primary"
disabled={isSubmitting}
>
Submit
</Button>
</Form>
)}
</Formik>
</Box>
);
};
export default FormIntegratedTagInput;
This implementation:
- Uses Formik to manage form state and validation
- Integrates Yup for schema validation
- Shows how to connect the tag input to Formik's form values
- Demonstrates error handling in the context of a form
Advanced: Asynchronous Tag Suggestions
In many applications, tag suggestions come from an API rather than a static list. Let's build a tag input that fetches suggestions asynchronously:
import React, { useState, useEffect } from 'react';
import {
Autocomplete,
TextField,
Chip,
Box,
CircularProgress
} from '@mui/material';
const AsyncTagInput = () => {
const [selectedTags, setSelectedTags] = useState([]);
const [options, setOptions] = useState([]);
const [inputValue, setInputValue] = useState('');
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
// Simulate API call to fetch tag suggestions
const fetchSuggestions = async (query) => {
setLoading(true);
try {
// In a real app, this would be an API call
// For demo purposes, we'll simulate a delay and filter a local array
await new Promise(resolve => setTimeout(resolve, 1000));
const allPossibleTags = [
'React', 'JavaScript', 'TypeScript', 'HTML', 'CSS',
'Node.js', 'Express', 'MongoDB', 'Redux', 'Next.js',
'GraphQL', 'Apollo', 'Jest', 'Testing', 'Webpack',
'Babel', 'ESLint', 'Prettier', 'Git', 'GitHub',
'AWS', 'Docker', 'Kubernetes', 'CI/CD', 'DevOps'
];
// Filter tags based on query
const filteredTags = query
? allPossibleTags.filter(tag =>
tag.toLowerCase().includes(query.toLowerCase()))
: allPossibleTags;
setOptions(filteredTags);
} catch (error) {
console.error('Error fetching suggestions:', error);
setOptions([]);
} finally {
setLoading(false);
}
};
// Fetch suggestions when the dropdown is opened or input changes
useEffect(() => {
if (!open) {
return;
}
fetchSuggestions(inputValue);
}, [inputValue, open]);
const handleInputChange = (event, newInputValue) => {
setInputValue(newInputValue);
};
return (
<Box sx={{ width: '100%', maxWidth: 500 }}>
<Autocomplete
multiple
freeSolo
open={open}
onOpen={() => setOpen(true)}
onClose={() => setOpen(false)}
options={options}
loading={loading}
value={selectedTags}
onChange={(event, newValue) => setSelectedTags(newValue)}
inputValue={inputValue}
onInputChange={handleInputChange}
renderInput={(params) => (
<TextField
{...params}
label="Tags"
placeholder="Search for tags..."
InputProps={{
...params.InputProps,
endAdornment: (
<>
{loading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</>
),
}}
/>
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip
label={option}
{...getTagProps({ index })}
color="primary"
variant="outlined"
/>
))
}
/>
</Box>
);
};
export default AsyncTagInput;
This implementation includes:
- Asynchronous fetching of tag suggestions
- Loading indicator while suggestions are being fetched
- Debounced input to prevent excessive API calls
- Handling of dropdown open/close states
Creating a Tag Input with Custom Tag Components
Sometimes you need more customization than the standard Chip component offers. Let's create a tag input with custom tag components:
import React, { useState } from 'react';
import {
Autocomplete,
TextField,
Box,
Paper,
Avatar,
Typography,
IconButton
} from '@mui/material';
import { styled } from '@mui/material/styles';
import CloseIcon from '@mui/icons-material/Close';
// Custom tag component
const CustomTag = styled(Paper)(({ theme }) => ({
display: 'inline-flex',
alignItems: 'center',
padding: '4px 8px 4px 4px',
margin: '3px',
borderRadius: '16px',
backgroundColor: theme.palette.primary.light,
'&:hover': {
backgroundColor: theme.palette.primary.main,
},
}));
const TagAvatar = styled(Avatar)(({ theme }) => ({
width: 24,
height: 24,
marginRight: 8,
backgroundColor: theme.palette.primary.dark,
fontSize: '0.75rem',
}));
const TagLabel = styled(Typography)({
color: '#fff',
fontWeight: 500,
});
const CustomTagInput = () => {
// Sample data with more complex structure
const tagOptions = [
{ id: 1, name: 'React', category: 'frontend' },
{ id: 2, name: 'Angular', category: 'frontend' },
{ id: 3, name: 'Vue', category: 'frontend' },
{ id: 4, name: 'Node.js', category: 'backend' },
{ id: 5, name: 'Express', category: 'backend' },
{ id: 6, name: 'MongoDB', category: 'database' },
{ id: 7, name: 'PostgreSQL', category: 'database' },
{ id: 8, name: 'Docker', category: 'devops' },
{ id: 9, name: 'Kubernetes', category: 'devops' },
{ id: 10, name: 'AWS', category: 'cloud' },
];
const [selectedTags, setSelectedTags] = useState([]);
// Get category color based on tag category
const getCategoryColor = (category) => {
const colors = {
frontend: '#3f51b5',
backend: '#4caf50',
database: '#ff9800',
devops: '#f44336',
cloud: '#2196f3',
};
return colors[category] || '#9c27b0';
};
return (
<Box sx={{ width: '100%', maxWidth: 500 }}>
<Autocomplete
multiple
options={tagOptions}
getOptionLabel={(option) => option.name}
value={selectedTags}
onChange={(event, newValue) => setSelectedTags(newValue)}
renderInput={(params) => (
<TextField
{...params}
variant="outlined"
label="Select Technologies"
placeholder="Add technologies..."
/>
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => {
const { onDelete, ...tagProps } = getTagProps({ index });
return (
<CustomTag
key={option.id}
sx={{ backgroundColor: getCategoryColor(option.category) }}
{...tagProps}
>
<TagAvatar sx={{ backgroundColor: getCategoryColor(option.category) }}>
{option.name.charAt(0).toUpperCase()}
</TagAvatar>
<TagLabel>{option.name}</TagLabel>
<IconButton
size="small"
onClick={onDelete}
sx={{
ml: 0.5,
p: 0.25,
color: '#fff',
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.2)',
},
}}
>
<CloseIcon fontSize="small" />
</IconButton>
</CustomTag>
);
})
}
isOptionEqualToValue={(option, value) => option.id === value.id}
/>
</Box>
);
};
export default CustomTagInput;
In this implementation:
- We've created a completely custom tag component instead of using Chip
- Each tag includes an avatar with the first letter of the tag name
- Tags are color-coded based on their category
- We're using more complex objects as options instead of simple strings
- We specify how to compare options with
isOptionEqualToValue
Creating a Fully Featured Tag Input Component
Now let's combine everything we've learned to create a comprehensive tag input component that incorporates all the best features:
import React, { useState, useEffect, useCallback } from 'react';
import {
Autocomplete,
TextField,
Chip,
Box,
Typography,
CircularProgress,
Paper,
Popper,
ClickAwayListener
} from '@mui/material';
import { styled } from '@mui/material/styles';
import { debounce } from 'lodash';
// Custom styled components
const TagInputWrapper = styled(Box)(({ theme }) => ({
width: '100%',
'& .MuiAutocomplete-tag': {
margin: '3px',
maxWidth: '100%',
},
}));
const StyledPopper = styled(Popper)(({ theme }) => ({
boxShadow: '0 5px 15px rgba(0,0,0,0.1)',
borderRadius: theme.shape.borderRadius,
width: '100%',
zIndex: theme.zIndex.modal,
}));
const TagCount = styled(Typography)(({ theme }) => ({
marginTop: theme.spacing(0.5),
marginLeft: theme.spacing(1.5),
fontSize: '0.75rem',
color: theme.palette.text.secondary,
}));
const ErrorMessage = styled(Typography)(({ theme }) => ({
color: theme.palette.error.main,
fontSize: '0.75rem',
marginTop: theme.spacing(0.5),
marginLeft: theme.spacing(1.5),
}));
const AdvancedTagInput = ({
label = 'Tags',
placeholder = 'Add tags...',
value = [],
onChange,
freeSolo = true,
fetchSuggestions,
maxTags = 10,
minTags = 0,
validateTag,
className,
disabled = false,
required = false,
helperText,
sx,
}) => {
const [inputValue, setInputValue] = useState('');
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [error, setError] = useState('');
// Default validation function if none provided
const defaultValidateTag = (tag) => {
if (tag.length < 2) {
return 'Tag must be at least 2 characters long';
}
if (tag.length > 30) {
return 'Tag cannot exceed 30 characters';
}
if (!/^[a-zA-Z0-9.#+\-_ ]+$/.test(tag)) {
return 'Tag can only contain letters, numbers, and basic punctuation';
}
if (value.includes(tag)) {
return 'This tag has already been added';
}
return '';
};
const validateFn = validateTag || defaultValidateTag;
// Debounced function to fetch suggestions
const debouncedFetchSuggestions = useCallback(
debounce(async (query) => {
if (!fetchSuggestions) return;
setLoading(true);
try {
const results = await fetchSuggestions(query);
setOptions(results);
} catch (error) {
console.error('Error fetching suggestions:', error);
setOptions([]);
} finally {
setLoading(false);
}
}, 300),
[fetchSuggestions]
);
// Fetch suggestions when input changes or dropdown opens
useEffect(() => {
if (open && fetchSuggestions) {
debouncedFetchSuggestions(inputValue);
}
}, [inputValue, open, debouncedFetchSuggestions]);
const handleInputChange = (event, newInputValue) => {
setInputValue(newInputValue);
if (error) setError('');
};
const handleChange = (event, newValue) => {
// Handle deletion (which is always valid)
if (newValue.length < value.length) {
onChange(newValue);
return;
}
// Check max tags limit
if (newValue.length > maxTags) {
setError(`Maximum ${maxTags} tags allowed`);
return;
}
// Handle addition - get the new tag
const newTag = newValue[newValue.length - 1];
// Validate the new tag
const validationError = validateFn(newTag);
if (validationError) {
setError(validationError);
return;
}
onChange(newValue);
};
const handleKeyDown = (event) => {
if (event.key === 'Enter' && inputValue && freeSolo) {
event.preventDefault();
// Check max tags limit
if (value.length >= maxTags) {
setError(`Maximum ${maxTags} tags allowed`);
return;
}
// Validate the tag
const validationError = validateFn(inputValue);
if (validationError) {
setError(validationError);
return;
}
// Add the tag if it's valid
if (!value.includes(inputValue)) {
onChange([...value, inputValue]);
setInputValue('');
}
}
};
return (
<TagInputWrapper className={className} sx={sx}>
<ClickAwayListener onClickAway={() => setOpen(false)}>
<div>
<Autocomplete
multiple
freeSolo={freeSolo}
open={open}
onOpen={() => setOpen(true)}
onClose={() => setOpen(false)}
options={options.filter(option => !value.includes(option))}
loading={loading}
value={value}
onChange={handleChange}
inputValue={inputValue}
onInputChange={handleInputChange}
disabled={disabled}
PopperComponent={(props) => (
<StyledPopper {...props} placement="bottom-start" />
)}
renderInput={(params) => (
<TextField
{...params}
label={label}
placeholder={value.length > 0 ? placeholder : ''}
onKeyDown={handleKeyDown}
error={!!error}
required={required}
helperText={helperText}
InputProps={{
...params.InputProps,
endAdornment: (
<>
{loading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</>
),
}}
/>
)}
renderTags={(tagValue, getTagProps) =>
tagValue.map((option, index) => (
<Chip
label={option}
{...getTagProps({ index })}
color="primary"
variant="outlined"
disabled={disabled}
sx={{
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
/>
))
}
renderOption={(props, option) => (
<li {...props}>
<Typography noWrap>{option}</Typography>
</li>
)}
PaperComponent={(props) => (
<Paper elevation={3} {...props} />
)}
/>
</div>
</ClickAwayListener>
{error && <ErrorMessage>{error}</ErrorMessage>}
{!error && (
<TagCount>
{value.length}/{maxTags} tags
{minTags > 0 && ` (minimum ${minTags} required)`}
</TagCount>
)}
</TagInputWrapper>
);
};
// Usage example component
const AdvancedTagInputDemo = () => {
const [tags, setTags] = useState(['React', 'MUI']);
// Simulate API call for suggestions
const fetchTagSuggestions = async (query) => {
// In a real app, this would be an API call
await new Promise(resolve => setTimeout(resolve, 500));
const allTags = [
'React', 'JavaScript', 'TypeScript', 'HTML', 'CSS',
'Node.js', 'Express', 'MongoDB', 'Redux', 'Next.js',
'GraphQL', 'Apollo', 'Jest', 'Testing', 'Webpack'
];
return query
? allTags.filter(tag => tag.toLowerCase().includes(query.toLowerCase()))
: allTags;
};
return (
<Box sx={{ maxWidth: 600, p: 2 }}>
<Typography variant="h5" gutterBottom>
Advanced Tag Input
</Typography>
<AdvancedTagInput
label="Technologies"
placeholder="Search or add technologies..."
value={tags}
onChange={setTags}
fetchSuggestions={fetchTagSuggestions}
maxTags={8}
minTags={2}
required
helperText="Select technologies relevant to your project"
sx={{ mb: 4 }}
/>
<Typography variant="body1">
Selected tags: {tags.join(', ')}
</Typography>
</Box>
);
};
export default AdvancedTagInputDemo;
This comprehensive implementation includes:
- A reusable component with extensive configuration options
- Debounced async suggestion fetching
- Custom styling and animations
- Advanced validation with customizable rules
- Tag limits (min/max)
- Error and helper messages
- Accessibility enhancements
- Performance optimizations
Performance Optimization for Large Datasets
When working with large datasets, you need to optimize your tag input for performance. Here's how to handle virtualization and efficient rendering:
import React, { useState } from 'react';
import {
Autocomplete,
TextField,
Chip,
Box,
Typography
} from '@mui/material';
import { FixedSizeList } from 'react-window';
// Generate a large dataset for demonstration
const generateLargeDataset = () => {
const tags = [];
for (let i = 1; i <= 5000; i++) {
tags.push(`Tag ${i}`);
}
return tags;
};
const OptimizedTagInput = () => {
const largeTagList = generateLargeDataset();
const [selectedTags, setSelectedTags] = useState([]);
// Virtualized list renderer for dropdown options
const ListboxComponent = React.forwardRef(function ListboxComponent(props, ref) {
const { children, ...other } = props;
const itemCount = Array.isArray(children) ? children.length : 0;
const itemSize = 36;
return (
<div ref={ref}>
<div {...other}>
<FixedSizeList
height={Math.min(itemCount * itemSize, 250)}
width="100%"
itemSize={itemSize}
itemCount={itemCount}
overscanCount={5}
>
{({ index, style }) => {
return React.cloneElement(children[index], {
style: { ...style, top: style.top + 8 },
});
}}
</FixedSizeList>
</div>
</div>
);
});
return (
<Box sx={{ width: '100%', maxWidth: 500 }}>
<Typography variant="h6" gutterBottom>
Optimized Tag Input (5,000 options)
</Typography>
<Autocomplete
multiple
options={largeTagList}
value={selectedTags}
onChange={(event, newValue) => setSelectedTags(newValue)}
renderInput={(params) => (
<TextField
{...params}
variant="outlined"
label="Tags"
placeholder="Add tags..."
/>
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip
label={option}
{...getTagProps({ index })}
color="primary"
/>
))
}
ListboxComponent={ListboxComponent}
filterSelectedOptions
disableListWrap
/>
<Typography variant="body2" sx={{ mt: 2, color: 'text.secondary' }}>
Selected {selectedTags.length} tags out of 5,000 available options.
</Typography>
</Box>
);
};
export default OptimizedTagInput;
This implementation:
- Uses
react-window
for virtualized rendering of the dropdown options - Only renders visible items in the viewport, greatly improving performance
- Works efficiently with thousands of options
- Filters selected options from the dropdown to avoid duplicates
Accessibility Enhancements
Let's improve our tag input with enhanced accessibility features:
import React, { useState, useRef } from 'react';
import {
Autocomplete,
TextField,
Chip,
Box,
Typography,
FormHelperText,
FormControl,
InputLabel,
OutlinedInput,
FormGroup
} from '@mui/material';
import { visuallyHidden } from '@mui/utils';
const AccessibleTagInput = () => {
const tagOptions = [
'React', 'JavaScript', 'TypeScript', 'HTML', 'CSS',
'Node.js', 'Express', 'MongoDB', 'Redux', 'Next.js'
];
const [selectedTags, setSelectedTags] = useState([]);
const [inputValue, setInputValue] = useState('');
const inputRef = useRef(null);
// Generate a unique ID for ARIA labeling
const labelId = 'tag-input-label';
const helperTextId = 'tag-input-helper-text';
const tagsListId = 'selected-tags-list';
const handleChange = (event, newValue) => {
setSelectedTags(newValue);
// Announce changes to screen readers
const numTags = newValue.length;
const message = numTags === 1
? '1 tag selected'
: `${numTags} tags selected`;
announceToScreenReader(message);
};
const handleDelete = (tagToDelete) => {
const newTags = selectedTags.filter(tag => tag !== tagToDelete);
setSelectedTags(newTags);
// Announce deletion to screen readers
announceToScreenReader(`Removed tag ${tagToDelete}. ${newTags.length} tags remaining.`);
};
const handleKeyDown = (event) => {
if (event.key === 'Enter' && inputValue) {
event.preventDefault();
if (!selectedTags.includes(inputValue)) {
const newTags = [...selectedTags, inputValue];
setSelectedTags(newTags);
setInputValue('');
// Announce addition to screen readers
announceToScreenReader(`Added tag ${inputValue}. ${newTags.length} tags selected.`);
}
}
};
// Function to announce messages to screen readers
const announceToScreenReader = (message) => {
// Create or use an existing live region
let liveRegion = document.getElementById('tag-input-live-region');
if (!liveRegion) {
liveRegion = document.createElement('div');
liveRegion.id = 'tag-input-live-region';
liveRegion.setAttribute('aria-live', 'polite');
liveRegion.setAttribute('role', 'status');
Object.assign(liveRegion.style, {
position: 'absolute',
width: '1px',
height: '1px',
padding: '0',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: '0'
});
document.body.appendChild(liveRegion);
}
liveRegion.textContent = message;
};
return (
<Box sx={{ width: '100%', maxWidth: 500 }}>
<Typography variant="h6" id={labelId} gutterBottom>
Select Technologies for Your Project
</Typography>
<FormControl fullWidth variant="outlined">
<FormGroup
aria-labelledby={labelId}
aria-describedby={helperTextId}
>
<Autocomplete
multiple
freeSolo
id="accessible-tag-input"
options={tagOptions}
value={selectedTags}
onChange={handleChange}
inputValue={inputValue}
onInputChange={(event, newValue) => setInputValue(newValue)}
renderInput={(params) => (
<TextField
{...params}
ref={inputRef}
variant="outlined"
placeholder={selectedTags.length > 0 ? "Add more tags..." : "Add tags..."}
onKeyDown={handleKeyDown}
aria-describedby={helperTextId}
InputProps={{
...params.InputProps,
'aria-controls': tagsListId,
}}
/>
)}
renderTags={(value, getTagProps) => (
<Box
id={tagsListId}
role="list"
aria-label="Selected tags"
>
{value.map((option, index) => {
const { key, ...tagProps } = getTagProps({ index });
return (
<Chip
key={key}
label={option}
color="primary"
onDelete={() => handleDelete(option)}
role="listitem"
aria-label={`${option}. Press delete or backspace to remove this tag.`}
{...tagProps}
/>
);
})}
</Box>
)}
/>
</FormGroup>
<FormHelperText id={helperTextId}>
Enter technology tags relevant to your project. Press Enter to add a custom tag.
</FormHelperText>
</FormControl>
{/* Visually hidden but screen reader accessible instructions */}
<Typography sx={visuallyHidden}>
Use arrow keys to navigate suggestions. Press Enter to add the current input as a tag.
Selected tags can be removed by pressing the delete button or using backspace.
</Typography>
</Box>
);
};
export default AccessibleTagInput;
This implementation includes:
- ARIA attributes for improved screen reader support
- Live region announcements for dynamic changes
- Keyboard navigation enhancement
- Clear instructions for screen reader users
- Proper labeling and descriptions
- Role attributes for semantic HTML structure
Best Practices and Common Issues
When implementing tag inputs with MUI, keep these best practices in mind:
Best Practices
- Controlled vs. Uncontrolled Components
Always prefer controlled components for complex inputs like tag fields. This gives you more control over validation, state management, and integration with forms.
// Controlled component (recommended)
const [tags, setTags] = useState([]);
<Autocomplete
multiple
value={tags}
onChange={(event, newValue) => setTags(newValue)}
// ...other props
/>
// Uncontrolled component (less flexible)
<Autocomplete
multiple
defaultValue={[]}
// ...other props
/>
- Debouncing Input for API Calls
When fetching suggestions from an API, always debounce your requests to prevent excessive calls:
import { debounce } from 'lodash';
// Inside your component
const debouncedFetch = useCallback(
debounce((query) => {
fetchSuggestions(query);
}, 300),
[fetchSuggestions]
);
// Use debouncedFetch instead of fetchSuggestions directly
- Providing Clear User Feedback
Always provide clear feedback about validation, limits, and actions:
<Autocomplete
// ...other props
renderInput={(params) => (
<TextField
{...params}
error={!!error}
helperText={error || helperText}
/>
)}
/>
- Handling Large Datasets
For large datasets, always use virtualization and pagination:
// Use react-window for virtualization
// Implement pagination or infinite scrolling for API results
// Filter options client-side only when necessary
Common Issues and Solutions
- Issue: Tags are cut off or overflow the container
Solution: Use proper styling and ellipsis for long tags:
<Chip
label={option}
sx={{
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
/>
- Issue: Duplicate tags are being added
Solution: Check for duplicates before adding new tags:
const handleAddTag = (newTag) => {
if (!selectedTags.includes(newTag)) {
setSelectedTags([...selectedTags, newTag]);
} else {
setError('This tag has already been added');
}
};
- Issue: Performance issues with many tags
Solution: Optimize rendering and consider limiting the number of visible tags:
// Show a limited number of tags with a "+X more" indicator
const visibleTags = value.slice(0, 5);
const hiddenCount = value.length - visibleTags.length;
return (
<>
{visibleTags.map((option, index) => (
<Chip
key={index}
label={option}
onDelete={() => handleDelete(option)}
/>
))}
{hiddenCount > 0 && (
<Chip
label={`+${hiddenCount} more`}
onClick={() => setShowAllTags(true)}
/>
)}
</>
);
- Issue: Validation not working correctly
Solution: Implement comprehensive validation before adding tags:
const validateTag = (tag) => {
// Trim whitespace
tag = tag.trim();
// Check length
if (tag.length < 2) return 'Tag is too short';
if (tag.length > 30) return 'Tag is too long';
// Check for invalid characters
if (!/^[a-zA-Z0-9.#+\-_ ]+$/.test(tag)) {
return 'Tag contains invalid characters';
}
// Check for duplicates
if (selectedTags.includes(tag)) {
return 'Tag already exists';
}
return ''; // Empty string means valid
};
Wrapping Up
In this comprehensive guide, we've explored how to build robust tag input fields using MUI's Chip and Autocomplete components. We've covered everything from basic implementations to advanced features like custom styling, validation, async suggestions, accessibility enhancements, and performance optimizations.
The power of MUI lies in its flexibility and composability, allowing you to create sophisticated UI components that meet your specific requirements. By combining Chip and Autocomplete, you can create tag inputs that are not only functional but also provide an excellent user experience.
Remember to prioritize accessibility, performance, and user feedback in your implementations. With the techniques and examples provided in this guide, you're now equipped to build tag input fields that can handle complex scenarios and integrate seamlessly with your React applications.