Optimizing a Search component
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.
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⌗
- A primer on rendering: An Introduction to React Fiber
- Counting re-renders: React Docs on <Profiler>
- More about memoization: React Docs on memo