Building a search component poses an interesting challenge and opportunities for exploring React’s features and various optimization techniques. This article walks through a basic implementation and expands into key areas where performance and usability can be improved - such as an auto-complete functionality, debouncing the input, and memoizing the results.

React Search

We start with a simple Search component that consists of a text field for the query and a button that triggers the search. To simulate the behavior of asynchronously getting the results from an API with fetch, we define a fetchData function that takes the searchQuery parameter and uses it to filter the results from a hardcoded list of mockResults, returning them inside a Promise. In a real world scenario the backend would hit the database to return those results in a response, so we’re also wrapping them on JavaScript’s setTimeout to fake a delay of 200ms.

import React, { useState } from 'react';

const mockResults = [
	{ id: 1, name: 'Apple' },
	{ id: 2, name: 'Banana' },
	{ id: 3, name: 'Carrot' },
	{ id: 4, name: 'Daikon' },
	{ id: 5, name: 'Elderberry' }
];

const fetchData = async (searchQuery) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      const filteredResults = mockResults.filter(item => 
        item.toLowerCase().includes(searchQuery.toLowerCase())
      );
      resolve(filteredResults);
    }, 200); // Simulating a 200ms network+database delay
  });
};


const Search = () => {
	const [query, setQuery] = useState('');
	const [results, setResults] = useState([]);

	const handleSearch = () => {
		const response = await fetchData(query);
		setResults(response);
	};

	return (
		<div>
			<input
				type="text"
				value={query}
				onChange={(e) => setQuery(e.target.value)}
			/>
			<button onClick={handleSearch}>Search</button>
			<ul>
				{results.map((result) => (
					<li key={result.id}>{result.name}</li>
				))}
			</ul>
		</div>
	);
};

export default Search;

Optimize the UX: Auto-Complete

The searching experience can be enhanced by an auto-complete functionality, allowing users to see a list of results as they type in the search input, reducing the number of actions needed to get them.

This can be achieved by calling handleSearch automatically whenever the query state changes, so we pass it to the dependency array of a useEffect hook that now calls said function. We also reset the results if there’s no text on the input and removed the unneeded Search button from the returned JSX.

First, remember to import useEffect at the top:

import React, { useState, useEffect } from 'react';

And here’s how the Search component should look like now:

const Search = () => {
	const [query, setQuery] = useState('');
	const [results, setResults] = useState([]);

	useEffect(() => {
		if (query.length === 0) {
			setResults([]);
			return;
		}

		const handleSearch = async () => {
			const response = await fetchData(query);
			setResults(response);
		};

		handleSearch();
	}, [query]);

	return (
		<div>
			<input
				type="text"
				value={query}
				onChange={(e) => setQuery(e.target.value)}
			/>
			<ul>
				{results.map((result) => (
					<li key={result.id}>{result.name}</li>
				))}
			</ul>
		</div>
	);
};

Optimize the query: Debouncing the Input

Now each time the user types a letter on the input, the handleSearch function is called, which in turn calls fetchData and updates the results. This can become a bottleneck as the number of concurrent users and retrieved data grows, having each keystroke trigger a new API call can quickly become expensive.

The two most used techniques to avoid this are debouncing and throttling. The former delays the execution of a function until a certain amount of time has passed since the last time it was called, while the latter limits the number of times a function can be called in a given time frame.

In this case, we want to delay the execution of handleSearch until the user has stopped typing for a certain amount of time. This can be achieved by debouncing the onChange event of the input, which can be done by creating a useDebounce custom hook that receives a value and a delay and returns a debounced value.

Let’s call this file hooks/useDebounce.js:

import { useState, useEffect } from 'react';

export const useDebounce = (value, delay) => {
  const [debouncedValue, setDebouncedValue] = useState(value);
  
  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);
    
    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);

  return debouncedValue;
};

Then after importing it on the Search component:

import { useDebounce } from '../hooks/useDebounce';

We can initialize useDebounce and use the debounced query on the useEffect hook of the Search component, which should look like this:

const Search = () => {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const debouncedQuery = useDebounce(query, 300);

  useEffect(() => {
    const fetchData = async () => {
      const data = await fetchData(debouncedQuery);
      setResults(data);
    };
    if (debouncedQuery) {
      fetchData();
    }
  }, [debouncedQuery]);

	return (
		// Doesn't change
	);
};

Optimize the results: Memoizing the API response

Finally, whenever the fetchData function is called by changes on the debouncedQuery, the entire unordered list <ul> along with its list items <li> to get rerendered, even when the results don’t change. While it may not be an issue for small lists, this could become a performance issue as the items grow in number or become more complex.

Memoization prevents unnecessary rerenders when the same results are obtained, thus improving the performance. There are two ways to memoize results in React:

  • React.memo: Best suited for memoizing the entire component, including its rendered output. It’s most effective when you want to avoid re-renders of an entire component based on specific prop changes.

  • useMemo: More suited for memoizing the returned values of functions inside a component. It’s most effective when you want to avoid re-running specific calculations or operations within a component, based on the change of specific dependencies.

Given that we are aiming to prevent unnecessary rerenders of the results, not just a calculation or operation, React.memo is used in this example as it allows to memoize the rendering of the list.

For readability, we’ll extract the list of results into a separate component called components/Results.jsx:

import React, { memo } from 'react';

const Results = ({ results }) => {
	return (
		<ul>
			{results.map((result) => (
				<li key={result.id}>{result.name}</li>
			))}
		</ul>
	);
};

export default memo(Results);

Notice at the end that the exported Results component is wrapped in memo, which will prevent it from being rerendered if its props don’t change. Now we can import it on the Search component and use it to render the results:

import Results from './Results';

Replace the unordered list <ul> and its list items <li> returned by the Search with the Results component passing results as a prop:

return (
	<div>
		<input
			type="text"
			value={query}
			onChange={(e) => setQuery(e.target.value)}
		/>
		<Results results={results} />
	</div>
);

Further reading