Building a Searchable Product Dropdown with React MUI Autocomplete and Async API
As a front-end developer working with React applications, you've likely encountered the need for a searchable dropdown that fetches data from an API. The Material-UI (MUI) Autocomplete component offers a powerful solution for this common requirement, especially when building product search functionality. In this article, I'll walk you through creating a robust, production-ready product search dropdown using MUI's Autocomplete component with asynchronous API integration.
What You'll Learn
By the end of this guide, you'll be able to:
- Implement a fully functional product search dropdown with MUI Autocomplete
- Connect your Autocomplete to a REST API with proper loading states
- Handle asynchronous data fetching with debouncing for performance
- Customize the appearance and behavior of your Autocomplete component
- Implement advanced features like virtualization for large datasets
- Address common issues and apply best practices for production use
Understanding MUI Autocomplete Component
The Autocomplete component is one of the most versatile and complex components in the Material-UI library. At its core, it's a combination of a text input and a dropdown menu that provides suggestions as users type. What makes it particularly powerful is its flexibility in handling various data structures and its extensive customization options.
Before diving into implementation, let's understand what makes the Autocomplete component special and why it's ideal for product search functionality. Unlike a simple Select component, Autocomplete offers filtering, free-text entry, and rich customization of both input and dropdown items, making it perfect for search interfaces where users might not know exactly what they're looking for.
Key Features and Capabilities
The Autocomplete component offers several features that make it ideal for product search:
- Filtering Options: As users type, the component can filter through available options.
- Asynchronous Data Loading: It can work with data fetched from APIs on-demand.
- Custom Rendering: You can customize how options appear in the dropdown.
- Multiple Selection: It supports selecting multiple items when needed.
- Keyboard Navigation: Users can navigate options using keyboard shortcuts.
- Accessibility: Built with accessibility in mind, including proper ARIA attributes.
Autocomplete Component Deep Dive
Component Props Reference
The Autocomplete component comes with numerous props that control its behavior. Here's a breakdown of the essential ones you'll need for building a product search:
Prop | Type | Default | Description |
---|---|---|---|
options | array | [] | Array of options to display in the dropdown |
loading | boolean | false | If true, a loading indicator will be displayed |
value | any | null | The value of the Autocomplete component (controlled) |
onChange | function | - | Callback fired when the value changes |
getOptionLabel | function | (option) => option.toString() | Used to determine the string value for a given option |
renderOption | function | - | Used to customize the rendering of options |
renderInput | function | required | Used to customize the input rendering (required) |
filterOptions | function | Default filter | Determines the filtered options to be rendered |
isOptionEqualToValue | function | - | Used to determine if an option is equal to the current value |
freeSolo | boolean | false | If true, the Autocomplete is free solo, meaning that the user can enter arbitrary values |
autoComplete | boolean | false | If true, the browser's autocomplete feature is enabled |
open | boolean | undefined (uncontrolled) | Controls if the popup is open (controlled) |
onOpen | function | - | Callback fired when the popup requests to be opened |
onClose | function | - | Callback fired when the popup requests to be closed |
disableClearable | boolean | false | If true, the clear button is not displayed |
Controlled vs Uncontrolled Usage
The Autocomplete component can be used in both controlled and uncontrolled modes:
Controlled Mode:
In controlled mode, you explicitly manage the component's state through props like value
and onChange
. This gives you more control but requires more code.
import { useState } from 'react';
import { Autocomplete, TextField } from '@mui/material';
function ControlledAutocomplete() {
const [value, setValue] = useState(null);
return (
<Autocomplete
value={value}
onChange={(event, newValue) => {
setValue(newValue);
}}
options={['Option 1', 'Option 2', 'Option 3']}
renderInput={(params) => <TextField {...params} label="Controlled" />}
/>
);
}
Uncontrolled Mode: In uncontrolled mode, the component manages its own state internally. This is simpler but provides less control.
import { Autocomplete, TextField } from '@mui/material';
function UncontrolledAutocomplete() {
return (
<Autocomplete
defaultValue={null}
options={['Option 1', 'Option 2', 'Option 3']}
renderInput={(params) => <TextField {...params} label="Uncontrolled" />}
/>
);
}
For our product search implementation, we'll use the controlled approach as it gives us more flexibility when working with async data.
Customization Options
The Autocomplete component offers several ways to customize its appearance and behavior:
Styling with the sx Prop
The sx
prop provides a shorthand way to define custom styles:
<Autocomplete
sx={{
width: 300,
'& .MuiOutlinedInput-root': {
borderRadius: 2,
},
'& .MuiAutocomplete-popupIndicator': {
color: 'primary.main',
}
}}
options={options}
renderInput={(params) => <TextField {...params} label="Products" />}
/>
Theme Customization
You can customize the Autocomplete component globally through the theme:
import { createTheme, ThemeProvider } from '@mui/material/styles';
const theme = createTheme({
components: {
MuiAutocomplete: {
styleOverrides: {
root: {
'& .MuiOutlinedInput-root': {
borderRadius: 8,
}
},
paper: {
boxShadow: '0px 4px 20px rgba(0, 0, 0, 0.1)',
}
}
}
}
});
function App() {
return (
<ThemeProvider theme={theme}>
{/* Your components */}
</ThemeProvider>
);
}
Custom Option Rendering
One of the most powerful customization features is the ability to render custom option components:
<Autocomplete
options={products}
getOptionLabel={(option) => option.name}
renderOption={(props, option) => (
<li {...props}>
<img
src={option.thumbnail}
alt={option.name}
style={{ width: 40, marginRight: 10 }}
/>
<div>
<div>{option.name}</div>
<div style={{ fontSize: 12, color: 'gray' }}>${option.price}</div>
</div>
</li>
)}
renderInput={(params) => <TextField {...params} label="Products" />}
/>
Accessibility Features
The Autocomplete component is built with accessibility in mind. It includes:
- ARIA attributes: Proper roles and aria-* attributes for screen readers
- Keyboard navigation: Users can navigate options using arrow keys, select with Enter, and close with Escape
- Focus management: Proper focus handling for keyboard users
You can enhance accessibility further by:
<Autocomplete
options={products}
getOptionLabel={(option) => option.name}
renderInput={(params) => (
<TextField
{...params}
label="Products"
aria-label="Search for products"
InputProps={{
...params.InputProps,
'aria-describedby': 'product-search-description'
}}
/>
)}
/>
<div id="product-search-description" style={{ display: 'none' }}>
Search for products by name, type arrow keys to navigate results
</div>
Setting Up Your Project
Let's start by setting up a new React project with Material-UI. If you already have a project, you can skip to the next section.
Creating a New React Project
First, create a new React application using Create React App:
npx create-react-app product-search-app
cd product-search-app
Installing Dependencies
Next, install the required dependencies:
npm install @mui/material @mui/icons-material @emotion/react @emotion/styled axios
Here's what each package does:
@mui/material
and@mui/icons-material
: The core Material-UI components and icons@emotion/react
and@emotion/styled
: Required for MUI's styling systemaxios
: For making HTTP requests to our API
Building the Product Search Autocomplete
Now, let's build our product search component step by step.
Step 1: Create the Basic Autocomplete Component
First, let's create a basic Autocomplete component that will serve as the foundation for our product search:
import React, { useState } from 'react';
import { Autocomplete, TextField, CircularProgress } from '@mui/material';
function ProductSearch() {
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
const [value, setValue] = useState(null);
const [inputValue, setInputValue] = useState('');
return (
<Autocomplete
id="product-search"
options={options}
loading={loading}
value={value}
onChange={(event, newValue) => {
setValue(newValue);
}}
inputValue={inputValue}
onInputChange={(event, newInputValue) => {
setInputValue(newInputValue);
}}
getOptionLabel={(option) => option.title || ''}
isOptionEqualToValue={(option, value) => option.id === value.id}
renderInput={(params) => (
<TextField
{...params}
label="Search Products"
InputProps={{
...params.InputProps,
endAdornment: (
<React.Fragment>
{loading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</React.Fragment>
),
}}
/>
)}
sx={{ width: 300 }}
/>
);
}
export default ProductSearch;
This sets up the basic structure with:
- State for options, loading status, selected value, and input value
- Proper rendering of the input field with a loading indicator
- Basic configuration for option labels and equality checks
Step 2: Add Asynchronous API Integration
Now, let's add the ability to fetch products from an API as the user types:
import React, { useState, useEffect } from 'react';
import { Autocomplete, TextField, CircularProgress } from '@mui/material';
import axios from 'axios';
function ProductSearch() {
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
const [value, setValue] = useState(null);
const [inputValue, setInputValue] = useState('');
// Fetch products when input value changes
useEffect(() => {
// Don't fetch for empty or very short queries
if (inputValue.length < 2) {
setOptions([]);
return;
}
let active = true;
setLoading(true);
// Fetch products from the API
axios.get(`https://dummyjson.com/products/search?q=${inputValue}`)
.then((response) => {
if (active) {
setOptions(response.data.products || []);
setLoading(false);
}
})
.catch((error) => {
console.error('Error fetching products:', error);
if (active) {
setOptions([]);
setLoading(false);
}
});
// Cleanup function to prevent state updates if component unmounts
return () => {
active = false;
};
}, [inputValue]);
return (
<Autocomplete
id="product-search"
options={options}
loading={loading}
value={value}
onChange={(event, newValue) => {
setValue(newValue);
}}
inputValue={inputValue}
onInputChange={(event, newInputValue) => {
setInputValue(newInputValue);
}}
getOptionLabel={(option) => option.title || ''}
isOptionEqualToValue={(option, value) => option.id === value.id}
renderInput={(params) => (
<TextField
{...params}
label="Search Products"
InputProps={{
...params.InputProps,
endAdornment: (
<React.Fragment>
{loading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</React.Fragment>
),
}}
/>
)}
sx={{ width: 300 }}
/>
);
}
export default ProductSearch;
In this step, we've added:
- A useEffect hook that triggers API calls when the input value changes
- Loading state management
- A cleanup function to prevent state updates if the component unmounts
- Error handling for API requests
Step 3: Implement Debouncing for Better Performance
To avoid making too many API calls as the user types, let's implement debouncing:
import React, { useState, useEffect, useMemo } from 'react';
import { Autocomplete, TextField, CircularProgress } from '@mui/material';
import axios from 'axios';
import { debounce } from '@mui/material/utils';
function ProductSearch() {
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
const [value, setValue] = useState(null);
const [inputValue, setInputValue] = useState('');
// Create a debounced search function
const fetchProducts = useMemo(
() =>
debounce(async (query) => {
// Don't fetch for empty or very short queries
if (query.length < 2) {
setOptions([]);
setLoading(false);
return;
}
setLoading(true);
try {
const response = await axios.get(
`https://dummyjson.com/products/search?q=${query}`
);
setOptions(response.data.products || []);
} catch (error) {
console.error('Error fetching products:', error);
setOptions([]);
} finally {
setLoading(false);
}
}, 500), // 500ms delay
[]
);
// Call the debounced function when input changes
useEffect(() => {
fetchProducts(inputValue);
// Cleanup
return () => {
fetchProducts.clear();
};
}, [inputValue, fetchProducts]);
return (
<Autocomplete
id="product-search"
options={options}
loading={loading}
value={value}
onChange={(event, newValue) => {
setValue(newValue);
}}
inputValue={inputValue}
onInputChange={(event, newInputValue) => {
setInputValue(newInputValue);
}}
getOptionLabel={(option) => option.title || ''}
isOptionEqualToValue={(option, value) => option.id === value.id}
noOptionsText="No products found"
loadingText="Searching products..."
renderInput={(params) => (
<TextField
{...params}
label="Search Products"
InputProps={{
...params.InputProps,
endAdornment: (
<React.Fragment>
{loading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</React.Fragment>
),
}}
/>
)}
sx={{ width: 300 }}
/>
);
}
export default ProductSearch;
In this step, we've:
- Implemented debouncing using MUI's built-in debounce utility
- Added proper cleanup to prevent memory leaks
- Added helpful text for loading and no options states
Step 4: Enhance the UI with Custom Option Rendering
Let's make our product search more visually appealing by customizing how options are displayed:
import React, { useState, useEffect, useMemo } from 'react';
import {
Autocomplete,
TextField,
CircularProgress,
Box,
Typography,
Divider
} from '@mui/material';
import axios from 'axios';
import { debounce } from '@mui/material/utils';
function ProductSearch() {
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
const [value, setValue] = useState(null);
const [inputValue, setInputValue] = useState('');
// Create a debounced search function
const fetchProducts = useMemo(
() =>
debounce(async (query) => {
if (query.length < 2) {
setOptions([]);
setLoading(false);
return;
}
setLoading(true);
try {
const response = await axios.get(
`https://dummyjson.com/products/search?q=${query}`
);
setOptions(response.data.products || []);
} catch (error) {
console.error('Error fetching products:', error);
setOptions([]);
} finally {
setLoading(false);
}
}, 500),
[]
);
useEffect(() => {
fetchProducts(inputValue);
return () => {
fetchProducts.clear();
};
}, [inputValue, fetchProducts]);
return (
<Autocomplete
id="product-search"
options={options}
loading={loading}
value={value}
onChange={(event, newValue) => {
setValue(newValue);
}}
inputValue={inputValue}
onInputChange={(event, newInputValue) => {
setInputValue(newInputValue);
}}
getOptionLabel={(option) => option.title || ''}
isOptionEqualToValue={(option, value) => option.id === value.id}
noOptionsText="No products found"
loadingText="Searching products..."
renderOption={(props, option) => (
<li {...props} key={option.id}>
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<Box
component="img"
src={option.thumbnail}
alt={option.title}
sx={{
width: 50,
height: 50,
objectFit: 'contain',
mr: 2,
borderRadius: 1
}}
/>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="subtitle1" component="div">
{option.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{option.brand} • {option.category}
</Typography>
</Box>
<Typography variant="subtitle1" color="primary.main">
${option.price}
</Typography>
</Box>
</li>
)}
renderInput={(params) => (
<TextField
{...params}
label="Search Products"
placeholder="Start typing to search..."
InputProps={{
...params.InputProps,
endAdornment: (
<React.Fragment>
{loading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</React.Fragment>
),
}}
/>
)}
sx={{ width: 400 }}
/>
);
}
export default ProductSearch;
In this step, we've:
- Added a custom option renderer with product images, titles, categories, and prices
- Improved the layout with MUI's Box component for flexbox layouts
- Added typography variations for better readability
- Expanded the width of the component to accommodate the richer content
Step 5: Add Error Handling and User Feedback
Let's enhance our component with better error handling and user feedback:
import React, { useState, useEffect, useMemo } from 'react';
import {
Autocomplete,
TextField,
CircularProgress,
Box,
Typography,
Alert,
Snackbar
} from '@mui/material';
import axios from 'axios';
import { debounce } from '@mui/material/utils';
function ProductSearch() {
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
const [value, setValue] = useState(null);
const [inputValue, setInputValue] = useState('');
const [error, setError] = useState(null);
// Create a debounced search function
const fetchProducts = useMemo(
() =>
debounce(async (query) => {
if (query.length < 2) {
setOptions([]);
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const response = await axios.get(
`https://dummyjson.com/products/search?q=${query}`
);
setOptions(response.data.products || []);
} catch (error) {
console.error('Error fetching products:', error);
setOptions([]);
setError('Failed to fetch products. Please try again later.');
} finally {
setLoading(false);
}
}, 500),
[]
);
useEffect(() => {
fetchProducts(inputValue);
return () => {
fetchProducts.clear();
};
}, [inputValue, fetchProducts]);
// Handle product selection
const handleProductSelect = (event, newValue) => {
setValue(newValue);
if (newValue) {
console.log('Selected product:', newValue);
// Here you would typically do something with the selected product
// Like adding it to a cart, navigating to a product page, etc.
}
};
return (
<Box>
<Autocomplete
id="product-search"
options={options}
loading={loading}
value={value}
onChange={handleProductSelect}
inputValue={inputValue}
onInputChange={(event, newInputValue) => {
setInputValue(newInputValue);
}}
getOptionLabel={(option) => option.title || ''}
isOptionEqualToValue={(option, value) => option.id === value.id}
noOptionsText={
inputValue.length > 1
? "No products found"
: "Type at least 2 characters to search"
}
loadingText="Searching products..."
renderOption={(props, option) => (
<li {...props} key={option.id}>
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<Box
component="img"
src={option.thumbnail}
alt={option.title}
sx={{
width: 50,
height: 50,
objectFit: 'contain',
mr: 2,
borderRadius: 1
}}
onError={(e) => {
e.target.src = 'https://placehold.co/50x50?text=No+Image';
}}
/>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="subtitle1" component="div">
{option.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{option.brand} • {option.category}
</Typography>
</Box>
<Typography variant="subtitle1" color="primary.main">
${option.price}
</Typography>
</Box>
</li>
)}
renderInput={(params) => (
<TextField
{...params}
label="Search Products"
placeholder="Start typing to search..."
error={!!error}
helperText={inputValue.length === 1 ? "Please enter at least 2 characters" : ""}
InputProps={{
...params.InputProps,
endAdornment: (
<React.Fragment>
{loading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</React.Fragment>
),
}}
/>
)}
sx={{ width: 400 }}
/>
<Snackbar
open={!!error}
autoHideDuration={6000}
onClose={() => setError(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="error" onClose={() => setError(null)}>
{error}
</Alert>
</Snackbar>
</Box>
);
}
export default ProductSearch;
In this step, we've:
- Added error state and a Snackbar to display error messages
- Improved the noOptionsText to provide better guidance
- Added a helper text for when the user types only one character
- Added an error handler for images that fail to load
- Created a dedicated handler for product selection
Step 6: Implement Virtualization for Large Datasets
When dealing with large datasets, rendering all options can cause performance issues. Let's implement virtualization to handle this:
import React, { useState, useEffect, useMemo } from 'react';
import {
Autocomplete,
TextField,
CircularProgress,
Box,
Typography,
Alert,
Snackbar,
ListSubheader
} from '@mui/material';
import { VariableSizeList } from 'react-window';
import axios from 'axios';
import { debounce } from '@mui/material/utils';
// Component for virtualized list
const VirtualizedListbox = React.forwardRef(function VirtualizedListbox(props, ref) {
const { children, ...other } = props;
const itemCount = Array.isArray(children) ? children.length : 0;
const itemSize = 60; // Height of each item
const getItemSize = (index) => {
return itemSize;
};
const outerElementType = React.forwardRef((props, ref) => {
return <div ref={ref} {...props} />;
});
return (
<div ref={ref}>
<VariableSizeList
itemCount={itemCount}
itemSize={getItemSize}
height={Math.min(itemCount * itemSize, 300)} // Max height 300px
width="100%"
outerElementType={outerElementType}
{...other}
>
{({ index, style }) => (
<div style={style}>{children[index]}</div>
)}
</VariableSizeList>
</div>
);
});
function ProductSearch() {
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
const [value, setValue] = useState(null);
const [inputValue, setInputValue] = useState('');
const [error, setError] = useState(null);
// Create a debounced search function
const fetchProducts = useMemo(
() =>
debounce(async (query) => {
if (query.length < 2) {
setOptions([]);
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const response = await axios.get(
`https://dummyjson.com/products/search?q=${query}`
);
setOptions(response.data.products || []);
} catch (error) {
console.error('Error fetching products:', error);
setOptions([]);
setError('Failed to fetch products. Please try again later.');
} finally {
setLoading(false);
}
}, 500),
[]
);
useEffect(() => {
fetchProducts(inputValue);
return () => {
fetchProducts.clear();
};
}, [inputValue, fetchProducts]);
// Group products by category for better organization
const groupedOptions = useMemo(() => {
const groups = {};
options.forEach((option) => {
if (!groups[option.category]) {
groups[option.category] = [];
}
groups[option.category].push(option);
});
return groups;
}, [options]);
return (
<Box>
<Autocomplete
id="product-search"
options={options}
loading={loading}
value={value}
onChange={(event, newValue) => {
setValue(newValue);
}}
inputValue={inputValue}
onInputChange={(event, newInputValue) => {
setInputValue(newInputValue);
}}
getOptionLabel={(option) => option.title || ''}
isOptionEqualToValue={(option, value) => option.id === value.id}
noOptionsText={
inputValue.length > 1
? "No products found"
: "Type at least 2 characters to search"
}
loadingText="Searching products..."
ListboxComponent={options.length > 10 ? VirtualizedListbox : undefined}
renderOption={(props, option) => (
<li {...props} key={option.id} style={{ height: 60 }}>
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<Box
component="img"
src={option.thumbnail}
alt={option.title}
sx={{
width: 50,
height: 50,
objectFit: 'contain',
mr: 2,
borderRadius: 1
}}
onError={(e) => {
e.target.src = 'https://placehold.co/50x50?text=No+Image';
}}
/>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="subtitle1" component="div">
{option.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{option.brand} • {option.category}
</Typography>
</Box>
<Typography variant="subtitle1" color="primary.main">
${option.price}
</Typography>
</Box>
</li>
)}
renderInput={(params) => (
<TextField
{...params}
label="Search Products"
placeholder="Start typing to search..."
error={!!error}
helperText={inputValue.length === 1 ? "Please enter at least 2 characters" : ""}
InputProps={{
...params.InputProps,
endAdornment: (
<React.Fragment>
{loading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</React.Fragment>
),
}}
/>
)}
sx={{ width: 400 }}
/>
<Snackbar
open={!!error}
autoHideDuration={6000}
onClose={() => setError(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="error" onClose={() => setError(null)}>
{error}
</Alert>
</Snackbar>
</Box>
);
}
export default ProductSearch;
In this step, we've:
- Implemented virtualization using react-window's VariableSizeList
- Applied virtualization conditionally when there are more than 10 items
- Set a fixed height for each item to ensure proper rendering
- Created a custom ListboxComponent for the virtualized list
Step 7: Integrate with a Form and Handle Form Submission
Let's integrate our ProductSearch component with a form to demonstrate a practical use case:
import React, { useState, useEffect, useMemo } from 'react';
import {
Autocomplete,
TextField,
CircularProgress,
Box,
Typography,
Alert,
Snackbar,
Button,
Paper,
Grid
} from '@mui/material';
import { VariableSizeList } from 'react-window';
import axios from 'axios';
import { debounce } from '@mui/material/utils';
// Component for virtualized list
const VirtualizedListbox = React.forwardRef(function VirtualizedListbox(props, ref) {
const { children, ...other } = props;
const itemCount = Array.isArray(children) ? children.length : 0;
const itemSize = 60; // Height of each item
const getItemSize = (index) => {
return itemSize;
};
const outerElementType = React.forwardRef((props, ref) => {
return <div ref={ref} {...props} />;
});
return (
<div ref={ref}>
<VariableSizeList
itemCount={itemCount}
itemSize={getItemSize}
height={Math.min(itemCount * itemSize, 300)} // Max height 300px
width="100%"
outerElementType={outerElementType}
{...other}
>
{({ index, style }) => (
<div style={style}>{children[index]}</div>
)}
</VariableSizeList>
</div>
);
});
function ProductOrderForm() {
const [product, setProduct] = useState(null);
const [quantity, setQuantity] = useState(1);
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
const [inputValue, setInputValue] = useState('');
const [error, setError] = useState(null);
const [success, setSuccess] = useState(null);
// Create a debounced search function
const fetchProducts = useMemo(
() =>
debounce(async (query) => {
if (query.length < 2) {
setOptions([]);
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const response = await axios.get(
`https://dummyjson.com/products/search?q=${query}`
);
setOptions(response.data.products || []);
} catch (error) {
console.error('Error fetching products:', error);
setOptions([]);
setError('Failed to fetch products. Please try again later.');
} finally {
setLoading(false);
}
}, 500),
[]
);
useEffect(() => {
fetchProducts(inputValue);
return () => {
fetchProducts.clear();
};
}, [inputValue, fetchProducts]);
// Handle form submission
const handleSubmit = (event) => {
event.preventDefault();
if (!product) {
setError('Please select a product');
return;
}
if (quantity < 1) {
setError('Please enter a valid quantity');
return;
}
// Here you would typically submit the order to your backend
console.log('Submitting order:', {
product,
quantity,
total: product.price * quantity
});
// Show success message
setSuccess(`Added ${quantity} x ${product.title} to cart`);
// Reset form
setProduct(null);
setQuantity(1);
setInputValue('');
};
return (
<Paper elevation={3} sx={{ p: 3, maxWidth: 600, mx: 'auto', mt: 4 }}>
<Typography variant="h5" component="h2" gutterBottom>
Product Order Form
</Typography>
<form onSubmit={handleSubmit}>
<Grid container spacing={3}>
<Grid item xs={12}>
<Autocomplete
id="product-search"
options={options}
loading={loading}
value={product}
onChange={(event, newValue) => {
setProduct(newValue);
}}
inputValue={inputValue}
onInputChange={(event, newInputValue) => {
setInputValue(newInputValue);
}}
getOptionLabel={(option) => option.title || ''}
isOptionEqualToValue={(option, value) => option.id === value.id}
noOptionsText={
inputValue.length > 1
? "No products found"
: "Type at least 2 characters to search"
}
loadingText="Searching products..."
ListboxComponent={options.length > 10 ? VirtualizedListbox : undefined}
renderOption={(props, option) => (
<li {...props} key={option.id} style={{ height: 60 }}>
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<Box
component="img"
src={option.thumbnail}
alt={option.title}
sx={{
width: 50,
height: 50,
objectFit: 'contain',
mr: 2,
borderRadius: 1
}}
onError={(e) => {
e.target.src = 'https://placehold.co/50x50?text=No+Image';
}}
/>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="subtitle1" component="div">
{option.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{option.brand} • {option.category}
</Typography>
</Box>
<Typography variant="subtitle1" color="primary.main">
${option.price}
</Typography>
</Box>
</li>
)}
renderInput={(params) => (
<TextField
{...params}
label="Search Products"
placeholder="Start typing to search..."
error={!!error && !product}
helperText={inputValue.length === 1 ? "Please enter at least 2 characters" : ""}
required
fullWidth
InputProps={{
...params.InputProps,
endAdornment: (
<React.Fragment>
{loading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</React.Fragment>
),
}}
/>
)}
/>
</Grid>
<Grid item xs={12}>
<TextField
label="Quantity"
type="number"
value={quantity}
onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value) || 1))}
required
fullWidth
inputProps={{ min: 1 }}
/>
</Grid>
{product && (
<Grid item xs={12}>
<Paper variant="outlined" sx={{ p: 2, mt: 2 }}>
<Typography variant="subtitle1">Order Summary</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1 }}>
<Typography>Product:</Typography>
<Typography>{product.title}</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography>Price:</Typography>
<Typography>${product.price}</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography>Quantity:</Typography>
<Typography>{quantity}</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', fontWeight: 'bold', mt: 1 }}>
<Typography>Total:</Typography>
<Typography>${(product.price * quantity).toFixed(2)}</Typography>
</Box>
</Paper>
</Grid>
)}
<Grid item xs={12}>
<Button
type="submit"
variant="contained"
color="primary"
fullWidth
disabled={!product}
>
Add to Cart
</Button>
</Grid>
</Grid>
</form>
<Snackbar
open={!!error}
autoHideDuration={6000}
onClose={() => setError(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="error" onClose={() => setError(null)}>
{error}
</Alert>
</Snackbar>
<Snackbar
open={!!success}
autoHideDuration={6000}
onClose={() => setSuccess(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="success" onClose={() => setSuccess(null)}>
{success}
</Alert>
</Snackbar>
</Paper>
);
}
export default ProductOrderForm;
In this step, we've:
- Created a complete product order form with quantity input
- Added form validation
- Implemented a dynamic order summary
- Added success and error notifications
- Styled the form with Paper and Grid components for a polished look
Advanced Capabilities
Now that we've built a functional product search component, let's explore some advanced capabilities you can add.
Caching Search Results
To improve performance, you can implement caching for search results:
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { Autocomplete, TextField, CircularProgress } from '@mui/material';
import axios from 'axios';
import { debounce } from '@mui/material/utils';
function ProductSearch() {
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
const [value, setValue] = useState(null);
const [inputValue, setInputValue] = useState('');
// Cache for search results
const cache = useRef({});
// Create a debounced search function with caching
const fetchProducts = useMemo(
() =>
debounce(async (query) => {
if (query.length < 2) {
setOptions([]);
setLoading(false);
return;
}
// Check cache first
if (cache.current[query]) {
setOptions(cache.current[query]);
return;
}
setLoading(true);
try {
const response = await axios.get(
`https://dummyjson.com/products/search?q=${query}`
);
const products = response.data.products || [];
// Update cache
cache.current[query] = products;
setOptions(products);
} catch (error) {
console.error('Error fetching products:', error);
setOptions([]);
} finally {
setLoading(false);
}
}, 500),
[]
);
useEffect(() => {
fetchProducts(inputValue);
return () => {
fetchProducts.clear();
};
}, [inputValue, fetchProducts]);
return (
<Autocomplete
// Component props as before
/>
);
}
Custom Filtering
You can implement custom filtering logic for more advanced search capabilities:
import { Autocomplete, TextField } from '@mui/material';
import { createFilterOptions } from '@mui/material/Autocomplete';
// Custom filter function
const filterOptions = createFilterOptions({
matchFrom: 'any',
stringify: (option) => `${option.title} ${option.brand} ${option.category} ${option.description}`,
});
function ProductSearch() {
// ... other state and logic
return (
<Autocomplete
filterOptions={filterOptions}
// Other props as before
/>
);
}
Infinite Scrolling
For very large datasets, you can implement infinite scrolling:
import React, { useState, useEffect, useCallback } from 'react';
import { Autocomplete, TextField, CircularProgress, Box } from '@mui/material';
import axios from 'axios';
import InfiniteScroll from 'react-infinite-scroll-component';
function ProductSearch() {
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
const [value, setValue] = useState(null);
const [inputValue, setInputValue] = useState('');
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const fetchProducts = useCallback(async (query, pageNum) => {
if (query.length < 2) return;
setLoading(true);
try {
const response = await axios.get(
`https://dummyjson.com/products/search?q=${query}&skip=${(pageNum - 1) * 20}&limit=20`
);
const newProducts = response.data.products || [];
if (pageNum === 1) {
setOptions(newProducts);
} else {
setOptions(prev => [...prev, ...newProducts]);
}
setHasMore(newProducts.length === 20);
} catch (error) {
console.error('Error fetching products:', error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
setPage(1);
setHasMore(true);
fetchProducts(inputValue, 1);
}, [inputValue, fetchProducts]);
const loadMoreData = () => {
const nextPage = page + 1;
setPage(nextPage);
fetchProducts(inputValue, nextPage);
};
// Custom listbox component with infinite scroll
const ListboxComponent = React.forwardRef((props, ref) => {
const { children, ...other } = props;
return (
<div ref={ref}>
<InfiniteScroll
dataLength={options.length}
next={loadMoreData}
hasMore={hasMore}
loader={<Box sx={{ display: 'flex', justifyContent: 'center', p: 1 }}>
<CircularProgress size={20} />
</Box>}
height={300}
{...other}
>
{children}
</InfiniteScroll>
</div>
);
});
return (
<Autocomplete
ListboxComponent={ListboxComponent}
// Other props as before
/>
);
}
Best Practices and Common Issues
Performance Optimization
-
Debounce Input Changes: Always debounce API calls to prevent excessive requests.
-
Virtualization for Large Lists: Use
react-window
for rendering large lists of options. -
Memoize Components and Functions: Use
useMemo
anduseCallback
to prevent unnecessary re-renders. -
Implement Caching: Cache API responses to avoid redundant network requests.
-
Lazy Loading Images: Implement lazy loading for product images in the dropdown.
<img
src={option.thumbnail}
alt={option.title}
loading="lazy"
// Other props
/>
Common Issues and Solutions
Issue 1: Dropdown Positioning Problems
Problem: The dropdown appears in the wrong position or gets cut off by the viewport.
Solution: Use the PopperComponent
prop to customize the positioning:
import { Popper } from '@mui/material';
function CustomPopper(props) {
return <Popper {...props} placement="bottom-start" />;
}
// In your component
<Autocomplete
PopperComponent={CustomPopper}
// Other props
/>
Issue 2: Slow Performance with Large Datasets
Problem: The component becomes sluggish with large datasets.
Solution: Implement server-side pagination and virtualization:
// Server-side pagination approach
const fetchProducts = async (query, page = 1, limit = 20) => {
return axios.get(`/api/products?search=${query}&page=${page}&limit=${limit}`);
};
Issue 3: Form Integration Issues
Problem: The Autocomplete doesn't work well with form libraries like Formik or React Hook Form.
Solution: Create a custom integration:
import { useFormik } from 'formik';
import { Autocomplete, TextField, Button } from '@mui/material';
function ProductForm() {
const formik = useFormik({
initialValues: {
product: null,
},
onSubmit: (values) => {
console.log('Form submitted:', values);
},
});
return (
<form onSubmit={formik.handleSubmit}>
<Autocomplete
id="product"
options={products}
getOptionLabel={(option) => option.title || ''}
value={formik.values.product}
onChange={(_, newValue) => {
formik.setFieldValue('product', newValue);
}}
renderInput={(params) => (
<TextField
{...params}
name="product"
label="Product"
error={formik.touched.product && Boolean(formik.errors.product)}
helperText={formik.touched.product && formik.errors.product}
/>
)}
/>
<Button type="submit">Submit</Button>
</form>
);
}
Issue 4: Accessibility Concerns
Problem: The component may not be fully accessible to all users.
Solution: Enhance accessibility with ARIA attributes and keyboard navigation:
<Autocomplete
id="product-search"
options={options}
renderInput={(params) => (
<TextField
{...params}
label="Search Products"
aria-label="Search for products"
InputProps={{
...params.InputProps,
'aria-describedby': 'product-search-description'
}}
/>
)}
// Other props
/>
<div id="product-search-description" style={{ display: 'none' }}>
Search for products by name, brand, or category. Use arrow keys to navigate results.
</div>
Styling Best Practices
- Use the Theme System: Leverage MUI's theme system for consistent styling:
import { createTheme, ThemeProvider } from '@mui/material/styles';
const theme = createTheme({
components: {
MuiAutocomplete: {
styleOverrides: {
root: {
'& .MuiOutlinedInput-root': {
borderRadius: 8,
}
},
paper: {
boxShadow: '0px 4px 20px rgba(0, 0, 0, 0.1)',
borderRadius: 8,
},
listbox: {
padding: 0,
},
option: {
'&[aria-selected="true"]': {
backgroundColor: 'rgba(25, 118, 210, 0.12)',
},
'&.Mui-focused': {
backgroundColor: 'rgba(25, 118, 210, 0.08)',
},
}
}
}
}
});
function App() {
return (
<ThemeProvider theme={theme}>
<ProductSearch />
</ThemeProvider>
);
}
- Use the sx Prop for Component-Specific Styling:
<Autocomplete
sx={{
'& .MuiAutocomplete-inputRoot': {
borderRadius: 2,
backgroundColor: 'background.paper',
boxShadow: 1,
transition: theme => theme.transitions.create(['box-shadow']),
'&:hover': {
boxShadow: 3,
},
'&.Mui-focused': {
boxShadow: 4,
}
}
}}
// Other props
/>
Integration with State Management
For larger applications, you might want to integrate your product search with a state management solution like Redux:
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Autocomplete, TextField, CircularProgress } from '@mui/material';
import { searchProducts, selectProduct } from './productSlice';
function ProductSearch() {
const dispatch = useDispatch();
const { products, loading, selectedProduct } = useSelector(state => state.products);
const [inputValue, setInputValue] = React.useState('');
useEffect(() => {
if (inputValue.length >= 2) {
dispatch(searchProducts(inputValue));
}
}, [inputValue, dispatch]);
return (
<Autocomplete
options={products}
loading={loading}
value={selectedProduct}
onChange={(event, newValue) => {
dispatch(selectProduct(newValue));
}}
inputValue={inputValue}
onInputChange={(event, newValue) => {
setInputValue(newValue);
}}
// Other props
/>
);
}
Wrapping Up
In this comprehensive guide, we've built a robust product search component using MUI's Autocomplete with asynchronous API integration. We've covered everything from basic implementation to advanced features like virtualization, caching, and custom styling.
The MUI Autocomplete component offers tremendous flexibility and power for creating searchable dropdowns. By combining it with proper API integration and performance optimizations, you can create a smooth, user-friendly product search experience that scales well with large datasets.
Remember to always consider performance, accessibility, and user experience when implementing search functionality in your applications. With the techniques covered in this guide, you're well-equipped to build production-ready search interfaces for your React applications.