Menu

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:

  1. Filtering Options: As users type, the component can filter through available options.
  2. Asynchronous Data Loading: It can work with data fetched from APIs on-demand.
  3. Custom Rendering: You can customize how options appear in the dropdown.
  4. Multiple Selection: It supports selecting multiple items when needed.
  5. Keyboard Navigation: Users can navigate options using keyboard shortcuts.
  6. 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:

PropTypeDefaultDescription
optionsarray[]Array of options to display in the dropdown
loadingbooleanfalseIf true, a loading indicator will be displayed
valueanynullThe value of the Autocomplete component (controlled)
onChangefunction-Callback fired when the value changes
getOptionLabelfunction(option) => option.toString()Used to determine the string value for a given option
renderOptionfunction-Used to customize the rendering of options
renderInputfunctionrequiredUsed to customize the input rendering (required)
filterOptionsfunctionDefault filterDetermines the filtered options to be rendered
isOptionEqualToValuefunction-Used to determine if an option is equal to the current value
freeSolobooleanfalseIf true, the Autocomplete is free solo, meaning that the user can enter arbitrary values
autoCompletebooleanfalseIf true, the browser's autocomplete feature is enabled
openbooleanundefined (uncontrolled)Controls if the popup is open (controlled)
onOpenfunction-Callback fired when the popup requests to be opened
onClosefunction-Callback fired when the popup requests to be closed
disableClearablebooleanfalseIf 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:

  1. ARIA attributes: Proper roles and aria-* attributes for screen readers
  2. Keyboard navigation: Users can navigate options using arrow keys, select with Enter, and close with Escape
  3. 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 system
  • axios: 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

  1. Debounce Input Changes: Always debounce API calls to prevent excessive requests.

  2. Virtualization for Large Lists: Use react-window for rendering large lists of options.

  3. Memoize Components and Functions: Use useMemo and useCallback to prevent unnecessary re-renders.

  4. Implement Caching: Cache API responses to avoid redundant network requests.

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

  1. 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>
  );
}
  1. 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.