Build an Autocomplete Search Bar with React
Build a production-ready autocomplete search component in React with TypeScript. Covers debouncing, keyboard navigation, accessibility, and caching.
Published:
Last Updated:
Introduction
Autocomplete search is one of the most impactful UI patterns on the web. Google, Amazon, GitHub, Spotify — every major product uses it. When users start typing and immediately see relevant suggestions, they find what they need faster, make fewer typos, and discover content they did not know existed. Research from the Baymard Institute shows that autocomplete search reduces search abandonment by up to 24%.
Yet building a production-quality autocomplete component is surprisingly difficult. A naive implementation — slap an onChange handler on an input, fire an API call on every keystroke, render results in a <div> — will work for a demo, but it will buckle under real-world usage. You need debouncing to avoid hammering your API. You need keyboard navigation so users can arrow through results. You need ARIA attributes so screen readers can announce suggestions. You need caching so repeated queries do not re-fetch. You need graceful handling of loading states, empty states, errors, and race conditions.
In this guide, we will build a fully-featured autocomplete search component from scratch in React with TypeScript. By the end, you will have a component that handles all of the above — and you will understand every line of code in it.
What we are building: A search bar that queries the REST Countries API as the user types, displays country suggestions with matched text highlighted, supports full keyboard navigation (arrow keys, Enter, Escape), announces results to screen readers, caches previous queries, and performs well even with hundreds of suggestions.
Build from scratch vs. use a library
Before we start writing code, here is an honest comparison to help you decide whether to build your own or reach for a library.
| Factor | Build from Scratch | Library (Downshift, React Select, etc.) |
|---|---|---|
| Learning value | High — you understand every piece | Low — abstracted away |
| Bundle size | Only what you need (0 KB added) | 5-25 KB gzipped |
| Customization | Total control over markup and behavior | Constrained by library API |
| Accessibility | You must implement it yourself | Usually handled for you |
| Time to production | 4-8 hours | 30-60 minutes |
| Maintenance | You own all the edge cases | Library maintainers handle updates |
| Best for | Learning, highly custom UIs, minimal bundles | Shipping fast, standard patterns |
Our recommendation: Build it from scratch at least once. Then use a library like Downshift for production if you do not need heavy customization. This guide gives you the knowledge to do both.
Project Setup
We will use Vite with React and TypeScript. If you already have a project, skip ahead to Step 1.
npm create vite@latest autocomplete-demo -- --template react-ts
cd autocomplete-demo
npm install
File structure
Here is how we will organize the component:
src/
components/
Autocomplete/
Autocomplete.tsx # Main component
Autocomplete.css # Styles
SuggestionItem.tsx # Individual suggestion row
useDebounce.ts # Debounce hook
useClickOutside.ts # Click outside hook
types.ts # TypeScript interfaces
App.tsx # Usage example
Types and interfaces
Let us define our data types first. We will use the REST Countries API, which returns country data including name, flag, population, and region.
// types.ts
export interface Country {
name: {
common: string;
official: string;
};
cca2: string;
flags: {
svg: string;
png: string;
};
population: number;
region: string;
capital?: string[];
}
export interface AutocompleteProps {
/** Placeholder text for the input */
placeholder?: string;
/** Minimum characters before suggestions appear */
minChars?: number;
/** Debounce delay in milliseconds */
debounceMs?: number;
/** Maximum number of suggestions to display */
maxSuggestions?: number;
/** Callback when a suggestion is selected */
onSelect?: (country: Country) => void;
}
export interface SuggestionItemProps {
country: Country;
query: string;
isActive: boolean;
onSelect: (country: Country) => void;
onMouseEnter: () => void;
id: string;
}
Notice that AutocompleteProps is configurable. The caller can control debounce timing, minimum character threshold, and maximum suggestions. This makes the component reusable across different contexts.
Step 1: Basic Search Input
We start with the simplest possible version: a controlled input that tracks what the user types.
// Autocomplete.tsx -- Step 1: Basic input
import { useState } from "react";
import type { AutocompleteProps } from "./types";
import "./Autocomplete.css";
export function Autocomplete({
placeholder = "Search countries...",
}: AutocompleteProps) {
const [query, setQuery] = useState("");
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
};
return (
<div className="autocomplete-wrapper">
<input
type="text"
className="autocomplete-input"
value={query}
onChange={handleChange}
placeholder={placeholder}
/>
</div>
);
}
Styling
Here is a clean, minimal stylesheet. We use CSS custom properties so the colors are easy to override.
/* Autocomplete.css */
.autocomplete-wrapper {
--ac-bg: #ffffff;
--ac-border: #d1d5db;
--ac-border-focus: #3b82f6;
--ac-text: #111827;
--ac-text-muted: #6b7280;
--ac-highlight: #3b82f6;
--ac-hover-bg: #f3f4f6;
--ac-active-bg: #e0e7ff;
--ac-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1),
0 2px 4px -2px rgb(0 0 0 / 0.1);
--ac-radius: 8px;
position: relative;
width: 100%;
max-width: 480px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.autocomplete-input {
width: 100%;
padding: 12px 16px;
font-size: 16px;
line-height: 1.5;
color: var(--ac-text);
background: var(--ac-bg);
border: 2px solid var(--ac-border);
border-radius: var(--ac-radius);
outline: none;
transition: border-color 0.15s ease;
box-sizing: border-box;
}
.autocomplete-input:focus {
border-color: var(--ac-border-focus);
}
.autocomplete-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 320px;
overflow-y: auto;
background: var(--ac-bg);
border: 1px solid var(--ac-border);
border-radius: var(--ac-radius);
box-shadow: var(--ac-shadow);
z-index: 50;
list-style: none;
margin: 0;
padding: 4px 0;
}
.suggestion-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
cursor: pointer;
transition: background-color 0.1s ease;
}
.suggestion-item:hover,
.suggestion-item[data-active="true"] {
background: var(--ac-hover-bg);
}
.suggestion-item[data-active="true"] {
background: var(--ac-active-bg);
}
.suggestion-flag {
width: 24px;
height: 16px;
object-fit: cover;
border-radius: 2px;
flex-shrink: 0;
}
.suggestion-name {
font-size: 14px;
color: var(--ac-text);
}
.suggestion-name mark {
background: none;
color: var(--ac-highlight);
font-weight: 600;
}
.suggestion-meta {
font-size: 12px;
color: var(--ac-text-muted);
margin-left: auto;
flex-shrink: 0;
}
.autocomplete-status {
padding: 12px 16px;
font-size: 14px;
color: var(--ac-text-muted);
text-align: center;
}
.autocomplete-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid var(--ac-border);
border-top-color: var(--ac-highlight);
border-radius: 50%;
animation: spin 0.6s linear infinite;
margin-right: 8px;
vertical-align: middle;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Screen reader only -- visually hidden but announced */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
At this point, you have a styled input that tracks its own value. Nothing exciting yet, but the foundation is solid.
Step 2: Fetching and Displaying Suggestions
Now we wire up the REST Countries API. When the user types, we fetch matching countries and render them in a dropdown.
// Autocomplete.tsx -- Step 2: Fetching suggestions
import { useState, useEffect } from "react";
import type { AutocompleteProps, Country } from "./types";
import "./Autocomplete.css";
async function fetchCountries(query: string): Promise<Country[]> {
if (!query.trim()) return [];
const response = await fetch(
`https://restcountries.com/v3.1/name/${encodeURIComponent(query)}?fields=name,cca2,flags,population,region,capital`
);
if (response.status === 404) return [];
if (!response.ok) throw new Error(`API error: ${response.status}`);
return response.json();
}
export function Autocomplete({
placeholder = "Search countries...",
minChars = 2,
maxSuggestions = 8,
onSelect,
}: AutocompleteProps) {
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<Country[]>([]);
const [isOpen, setIsOpen] = useState(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value);
};
// Fetch suggestions when query changes
useEffect(() => {
if (query.length < minChars) {
setSuggestions([]);
setIsOpen(false);
return;
}
let cancelled = false;
fetchCountries(query).then((results) => {
if (!cancelled) {
setSuggestions(results.slice(0, maxSuggestions));
setIsOpen(results.length > 0);
}
});
return () => {
cancelled = true;
};
}, [query, minChars, maxSuggestions]);
const handleSelect = (country: Country) => {
setQuery(country.name.common);
setIsOpen(false);
setSuggestions([]);
onSelect?.(country);
};
return (
<div className="autocomplete-wrapper">
<input
type="text"
className="autocomplete-input"
value={query}
onChange={handleChange}
placeholder={placeholder}
/>
{isOpen && suggestions.length > 0 && (
<ul className="autocomplete-dropdown">
{suggestions.map((country) => (
<li
key={country.cca2}
className="suggestion-item"
onClick={() => handleSelect(country)}
>
<img
src={country.flags.svg}
alt={`${country.name.common} flag`}
className="suggestion-flag"
/>
<span className="suggestion-name">
{country.name.common}
</span>
<span className="suggestion-meta">
{country.region}
</span>
</li>
))}
</ul>
)}
</div>
);
}
The cancelled flag pattern is critical. Without it, you get a race condition. If the user types “fra” and then “fran” quickly, the response for “fra” might arrive after “fran” and overwrite the correct results. Setting cancelled = true in the cleanup function ensures stale responses are discarded.
Highlighting matched text
A good autocomplete highlights the portion of each suggestion that matches what the user typed. Here is a utility function that wraps matched substrings in <mark> tags:
// SuggestionItem.tsx
import { memo } from "react";
import type { SuggestionItemProps } from "./types";
function highlightMatch(text: string, query: string): React.ReactNode {
if (!query.trim()) return text;
const regex = new RegExp(`(${escapeRegExp(query)})`, "gi");
const parts = text.split(regex);
return parts.map((part, i) =>
regex.test(part) ? <mark key={i}>{part}</mark> : part
);
}
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function formatPopulation(pop: number): string {
if (pop >= 1_000_000) return `${(pop / 1_000_000).toFixed(1)}M`;
if (pop >= 1_000) return `${(pop / 1_000).toFixed(0)}K`;
return pop.toString();
}
export const SuggestionItem = memo(function SuggestionItem({
country,
query,
isActive,
onSelect,
onMouseEnter,
id,
}: SuggestionItemProps) {
return (
<li
id={id}
role="option"
aria-selected={isActive}
className="suggestion-item"
data-active={isActive}
onClick={() => onSelect(country)}
onMouseEnter={onMouseEnter}
>
<img
src={country.flags.svg}
alt=""
className="suggestion-flag"
aria-hidden="true"
/>
<span className="suggestion-name">
{highlightMatch(country.name.common, query)}
</span>
<span className="suggestion-meta">
{country.region} · {formatPopulation(country.population)}
</span>
</li>
);
});
Notice three things about this component:
escapeRegExpprevents the user’s input from being interpreted as regex. Without this, typing(would crash the component.memoprevents unnecessary re-renders. If the country data and active state have not changed, the component skips rendering entirely. We will discuss this more in the performance section.- The flag image has
aria-hidden="true"because it is decorative. Screen readers should read the country name, not “United States flag image.”
Step 3: Debouncing
Right now, every keystroke triggers an API call. Type “united states” and that is 13 HTTP requests. Most of those responses will be thrown away before the user even sees them.
Without debouncing: The user types “fran” over 400ms. Four API calls fire: “f”, “fr”, “fra”, “fran”. Only the last result matters.
With debouncing: The user types “fran” over 400ms. We wait 300ms after the last keystroke. One API call fires: “fran”. Three wasted requests eliminated.
Custom useDebounce hook
// useDebounce.ts
import { useState, useEffect } from "react";
/**
* Debounces a value by the specified delay.
* Returns the debounced value, which only updates
* after `delay` ms of inactivity.
*/
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
This hook is elegantly simple. Every time value changes, it sets a timeout. If value changes again before the timeout fires, the cleanup function clears the old timeout and a new one starts. Only when the user stops changing the value for delay milliseconds does the debounced value update.
Integrating debounce with search
Update the main component to use the debounced query for API calls while keeping the input responsive:
// Inside Autocomplete.tsx
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
// Use debouncedQuery for API calls, not raw query
useEffect(() => {
if (debouncedQuery.length < minChars) {
setSuggestions([]);
setIsOpen(false);
return;
}
let cancelled = false;
fetchCountries(debouncedQuery).then((results) => {
if (!cancelled) {
setSuggestions(results.slice(0, maxSuggestions));
setIsOpen(results.length > 0);
}
});
return () => {
cancelled = true;
};
}, [debouncedQuery, minChars, maxSuggestions]);
The input still updates on every keystroke (so the user sees their typing immediately), but the API call only fires after 300ms of inactivity.
Comparison: useDebounce vs lodash.debounce vs useDeferredValue
| Approach | Bundle cost | SSR safe | Cancelable | Best for |
|---|---|---|---|---|
Custom useDebounce hook | 0 KB | Yes | Yes (via cleanup) | API calls, network requests |
lodash.debounce | ~1.4 KB (tree-shaken) | Yes | Yes (cancel()) | Event handlers, callbacks |
useDeferredValue (React 18+) | 0 KB (built-in) | Yes | Automatic | Expensive renders, filtering local data |
When to use which:
- Use our custom
useDebouncehook when you need to debounce a value that triggers side effects (API calls). It is the clearest, most React-idiomatic approach. - Use
lodash.debouncewhen you need to debounce a callback function (for example, a scroll handler or resize listener). Wrap it inuseMemoto avoid re-creating the debounced function on every render. - Use
useDeferredValuewhen you have expensive rendering (like filtering 10,000 items in a list). It tells React “this update can be interrupted by more urgent work.” It does not reduce API calls — it reduces rendering jank.
For autocomplete with a remote API, our custom useDebounce hook is the right choice.
Step 4: Keyboard Navigation
Users expect to navigate autocomplete suggestions with arrow keys, select with Enter, and close with Escape. This is not just a nice-to-have — it is an accessibility requirement.
// Keyboard navigation logic (inside Autocomplete.tsx)
import { useState, useEffect, useRef, useCallback } from "react";
// Add to component state:
const [activeIndex, setActiveIndex] = useState(-1);
const listRef = useRef<HTMLUListElement>(null);
// Reset active index when suggestions change
useEffect(() => {
setActiveIndex(-1);
}, [suggestions]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (!isOpen || suggestions.length === 0) {
// If user presses down arrow with no open dropdown,
// trigger a search if we have enough characters
if (e.key === "ArrowDown" && query.length >= minChars) {
setIsOpen(true);
}
return;
}
switch (e.key) {
case "ArrowDown": {
e.preventDefault(); // Prevent cursor from moving in input
setActiveIndex((prev) =>
prev < suggestions.length - 1 ? prev + 1 : 0
);
break;
}
case "ArrowUp": {
e.preventDefault();
setActiveIndex((prev) =>
prev > 0 ? prev - 1 : suggestions.length - 1
);
break;
}
case "Enter": {
e.preventDefault();
if (activeIndex >= 0 && activeIndex < suggestions.length) {
handleSelect(suggestions[activeIndex]);
}
break;
}
case "Escape": {
e.preventDefault();
setIsOpen(false);
setActiveIndex(-1);
break;
}
case "Tab": {
// Close dropdown when tabbing away
setIsOpen(false);
setActiveIndex(-1);
break;
}
}
},
[isOpen, suggestions, activeIndex, query, minChars]
);
Scrolling the active item into view
When the user arrows past the visible area of the dropdown, we need to scroll the active item into view:
// Scroll active suggestion into view
useEffect(() => {
if (activeIndex < 0 || !listRef.current) return;
const activeElement = listRef.current.children[activeIndex] as HTMLElement;
if (activeElement) {
activeElement.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
}, [activeIndex]);
Wire up the onKeyDown handler on the input:
<input
type="text"
className="autocomplete-input"
value={query}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
/>
And pass isActive and the ref to the dropdown list:
<ul className="autocomplete-dropdown" ref={listRef}>
{suggestions.map((country, index) => (
<SuggestionItem
key={country.cca2}
id={`suggestion-${index}`}
country={country}
query={debouncedQuery}
isActive={index === activeIndex}
onSelect={handleSelect}
onMouseEnter={() => setActiveIndex(index)}
/>
))}
</ul>
Note the onMouseEnter handler. When the user hovers over a suggestion with the mouse, we update activeIndex to match. This ensures that keyboard and mouse navigation stay in sync — a detail that many implementations miss.
Step 5: Accessibility (a11y)
Accessibility is not optional. The WAI-ARIA combobox pattern defines exactly how an autocomplete component should behave for assistive technologies. Here are the ARIA attributes we need:
On the wrapper <div>:
- No specific role needed, but it groups the input and listbox.
On the <input>:
role="combobox"— identifies this as a combobox widget.aria-autocomplete="list"— tells screen readers that a list of suggestions will appear.aria-expanded={isOpen}— announces whether the dropdown is open or closed.aria-controls="suggestions-list"— links the input to the listbox.aria-activedescendant— points to the currently highlighted suggestion’sid. This is how screen readers know which suggestion is “focused” without moving DOM focus away from the input.
On the <ul> dropdown:
role="listbox"— identifies this as a list of selectable options.id="suggestions-list"— matches thearia-controlsvalue.
On each <li> suggestion:
role="option"— identifies this as a selectable option within the listbox.id={"suggestion-" + index}— unique ID foraria-activedescendantto reference.aria-selectedset toisActive— marks the currently highlighted option.
Screen reader announcements with a live region
When the number of results changes, screen readers should announce it. We use an aria-live region:
// Live region for result count announcements
const announcementText = (() => {
if (!isOpen) return "";
if (suggestions.length === 0) return "No results found.";
if (suggestions.length === 1) return "1 suggestion available.";
return `${suggestions.length} suggestions available. Use arrow keys to navigate.`;
})();
{/* Visually hidden, but announced by screen readers */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcementText}
</div>
Here is the fully accessible input markup:
<input
type="text"
className="autocomplete-input"
value={query}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
role="combobox"
aria-autocomplete="list"
aria-expanded={isOpen}
aria-controls="suggestions-list"
aria-activedescendant={
activeIndex >= 0 ? `suggestion-${activeIndex}` : undefined
}
autoComplete="off"
spellCheck={false}
/>
Notice autoComplete="off" — this prevents the browser’s built-in autocomplete from competing with ours. And spellCheck={false} removes the red squiggly underlines that would distract from suggestions.
Step 6: Click Outside to Close
When the user clicks outside the autocomplete component, the dropdown should close. This is a common pattern that deserves its own hook.
// useClickOutside.ts
import { useEffect, type RefObject } from "react";
/**
* Calls `handler` when a click occurs outside
* the element referenced by `ref`.
*/
export function useClickOutside(
ref: RefObject<HTMLElement | null>,
handler: () => void,
enabled: boolean = true
) {
useEffect(() => {
if (!enabled) return;
function handleClick(event: MouseEvent) {
if (
ref.current &&
!ref.current.contains(event.target as Node)
) {
handler();
}
}
// Use mousedown, not click, so the dropdown closes
// before the click lands on whatever the user clicked.
document.addEventListener("mousedown", handleClick);
return () => {
document.removeEventListener("mousedown", handleClick);
};
}, [ref, handler, enabled]);
}
Usage in the component:
const wrapperRef = useRef<HTMLDivElement>(null);
useClickOutside(
wrapperRef,
() => {
setIsOpen(false);
setActiveIndex(-1);
},
isOpen // Only listen when the dropdown is open
);
// Wrap the component in the ref'd div
return (
<div className="autocomplete-wrapper" ref={wrapperRef}>
{/* ... */}
</div>
);
Why mousedown instead of click? The click event fires after mouseup. If the user presses down inside the dropdown and releases outside (or vice versa), click can produce unexpected behavior. mousedown fires immediately and gives a cleaner UX.
Why the enabled parameter? We only need the event listener when the dropdown is open. When it is closed, there is no reason to listen for outside clicks. This avoids adding unnecessary event listeners to the document.
Step 7: Caching Results
Every time the user deletes a character and retypes it, we should not re-fetch data we already have. A simple in-memory cache solves this.
// Cache implementation using useRef + Map
const cacheRef = useRef<Map<string, Country[]>>(new Map());
// Modified fetch logic with caching
useEffect(() => {
if (debouncedQuery.length < minChars) {
setSuggestions([]);
setIsOpen(false);
return;
}
const cacheKey = debouncedQuery.toLowerCase();
// Check cache first
if (cacheRef.current.has(cacheKey)) {
const cached = cacheRef.current.get(cacheKey)!;
setSuggestions(cached.slice(0, maxSuggestions));
setIsOpen(cached.length > 0);
return;
}
let cancelled = false;
setIsLoading(true);
fetchCountries(debouncedQuery)
.then((results) => {
if (!cancelled) {
// Store in cache
cacheRef.current.set(cacheKey, results);
// Evict oldest entries if cache is too large
if (cacheRef.current.size > 50) {
const firstKey = cacheRef.current.keys().next().value;
if (firstKey !== undefined) {
cacheRef.current.delete(firstKey);
}
}
setSuggestions(results.slice(0, maxSuggestions));
setIsOpen(results.length > 0);
}
})
.catch((err) => {
if (!cancelled) {
console.error("Autocomplete fetch error:", err);
setSuggestions([]);
}
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, [debouncedQuery, minChars, maxSuggestions]);
Why useRef and not useState? The cache should not trigger re-renders when it updates. useRef gives us a mutable container that persists across renders without causing them. Putting the cache in useState would cause the component to re-render every time we add a cache entry, which defeats the purpose.
Cache eviction: We use a simple LRU-like strategy: when the cache exceeds 50 entries, we delete the oldest one. For a country search, this is more than sufficient. In a production app with a more active cache, you might use a proper LRU implementation or set TTLs on entries.
Cache invalidation strategy
For most autocomplete use cases, the data does not change while the user is on the page. But if your data is volatile (real-time stock prices, live inventory), you have a few options:
- TTL-based expiration: Store a timestamp with each cache entry and ignore entries older than N seconds.
- Clear on focus: Wipe the cache when the user focuses the input, ensuring fresh data for each search session.
- Stale-while-revalidate: Show cached results immediately, then fetch fresh data in the background and update if the results differ.
Step 8: Loading and Empty States
A polished autocomplete handles three states beyond “showing results”: loading, no results, and below minimum characters.
// Add to component state:
const [isLoading, setIsLoading] = useState(false);
// In the JSX, after the input:
{isOpen && (
<ul
className="autocomplete-dropdown"
role="listbox"
id="suggestions-list"
ref={listRef}
>
{isLoading ? (
<li className="autocomplete-status" role="status">
<span className="autocomplete-spinner" aria-hidden="true" />
Searching...
</li>
) : suggestions.length === 0 ? (
<li className="autocomplete-status">
No countries found for "{debouncedQuery}"
</li>
) : (
suggestions.map((country, index) => (
<SuggestionItem
key={country.cca2}
id={`suggestion-${index}`}
country={country}
query={debouncedQuery}
isActive={index === activeIndex}
onSelect={handleSelect}
onMouseEnter={() => setActiveIndex(index)}
/>
))
)}
</ul>
)}
The minimum character threshold (minChars) prevents the dropdown from appearing when the user has only typed one character. For the REST Countries API, searching for “a” returns 60+ results, which is not useful. Requiring 2+ characters produces much more relevant suggestions.
Complete Component
Here is the entire component in a single file, fully commented:
// Autocomplete.tsx -- Complete implementation
import {
useState,
useEffect,
useRef,
useCallback,
type KeyboardEvent,
type ChangeEvent,
} from "react";
import { useDebounce } from "./useDebounce";
import { useClickOutside } from "./useClickOutside";
import { SuggestionItem } from "./SuggestionItem";
import type { AutocompleteProps, Country } from "./types";
import "./Autocomplete.css";
// --- API layer ---
async function fetchCountries(query: string): Promise<Country[]> {
if (!query.trim()) return [];
const url = `https://restcountries.com/v3.1/name/${encodeURIComponent(query)}?fields=name,cca2,flags,population,region,capital`;
const response = await fetch(url);
if (response.status === 404) return []; // No matches
if (!response.ok) throw new Error(`API error: ${response.status}`);
return response.json();
}
// --- Component ---
export function Autocomplete({
placeholder = "Search countries...",
minChars = 2,
debounceMs = 300,
maxSuggestions = 8,
onSelect,
}: AutocompleteProps) {
// --- State ---
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<Country[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
// --- Refs ---
const wrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const cacheRef = useRef<Map<string, Country[]>>(new Map());
// --- Derived values ---
const debouncedQuery = useDebounce(query, debounceMs);
// --- Click outside to close ---
useClickOutside(
wrapperRef,
useCallback(() => {
setIsOpen(false);
setActiveIndex(-1);
}, []),
isOpen
);
// --- Fetch suggestions (debounced) ---
useEffect(() => {
if (debouncedQuery.length < minChars) {
setSuggestions([]);
setIsOpen(false);
setIsLoading(false);
return;
}
const cacheKey = debouncedQuery.toLowerCase();
// Return cached results if available
if (cacheRef.current.has(cacheKey)) {
const cached = cacheRef.current.get(cacheKey)!;
setSuggestions(cached.slice(0, maxSuggestions));
setIsOpen(cached.length > 0);
setIsLoading(false);
return;
}
let cancelled = false;
setIsLoading(true);
fetchCountries(debouncedQuery)
.then((results) => {
if (cancelled) return;
// Cache the results
cacheRef.current.set(cacheKey, results);
// Evict oldest entry if cache exceeds limit
if (cacheRef.current.size > 50) {
const firstKey = cacheRef.current.keys().next().value;
if (firstKey !== undefined) {
cacheRef.current.delete(firstKey);
}
}
setSuggestions(results.slice(0, maxSuggestions));
setIsOpen(results.length > 0);
})
.catch((error) => {
if (cancelled) return;
console.error("Autocomplete fetch failed:", error);
setSuggestions([]);
setIsOpen(false);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, [debouncedQuery, minChars, maxSuggestions]);
// --- Reset active index when suggestions change ---
useEffect(() => {
setActiveIndex(-1);
}, [suggestions]);
// --- Scroll active item into view ---
useEffect(() => {
if (activeIndex < 0 || !listRef.current) return;
const activeEl = listRef.current.children[activeIndex] as HTMLElement;
activeEl?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}, [activeIndex]);
// --- Handlers ---
const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
}, []);
const handleSelect = useCallback(
(country: Country) => {
setQuery(country.name.common);
setIsOpen(false);
setSuggestions([]);
setActiveIndex(-1);
onSelect?.(country);
// Return focus to input after selection
inputRef.current?.focus();
},
[onSelect]
);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (!isOpen || suggestions.length === 0) {
if (e.key === "ArrowDown" && query.length >= minChars) {
e.preventDefault();
setIsOpen(true);
}
return;
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setActiveIndex((prev) =>
prev < suggestions.length - 1 ? prev + 1 : 0
);
break;
case "ArrowUp":
e.preventDefault();
setActiveIndex((prev) =>
prev > 0 ? prev - 1 : suggestions.length - 1
);
break;
case "Enter":
e.preventDefault();
if (activeIndex >= 0 && activeIndex < suggestions.length) {
handleSelect(suggestions[activeIndex]);
}
break;
case "Escape":
e.preventDefault();
setIsOpen(false);
setActiveIndex(-1);
break;
case "Tab":
setIsOpen(false);
setActiveIndex(-1);
break;
}
},
[isOpen, suggestions, activeIndex, query, minChars, handleSelect]
);
// --- Screen reader announcement ---
const announcementText = (() => {
if (!isOpen) return "";
if (isLoading) return "Searching...";
if (suggestions.length === 0) return "No results found.";
if (suggestions.length === 1) return "1 suggestion available.";
return `${suggestions.length} suggestions available. Use arrow keys to navigate.`;
})();
// --- Render ---
return (
<div className="autocomplete-wrapper" ref={wrapperRef}>
<input
ref={inputRef}
type="text"
className="autocomplete-input"
value={query}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
role="combobox"
aria-autocomplete="list"
aria-expanded={isOpen}
aria-controls="suggestions-list"
aria-activedescendant={
activeIndex >= 0 ? `suggestion-${activeIndex}` : undefined
}
autoComplete="off"
spellCheck={false}
/>
{isOpen && (
<ul
className="autocomplete-dropdown"
role="listbox"
id="suggestions-list"
ref={listRef}
>
{isLoading ? (
<li className="autocomplete-status" role="status">
<span className="autocomplete-spinner" aria-hidden="true" />
Searching...
</li>
) : suggestions.length === 0 ? (
<li className="autocomplete-status">
No countries found for "{debouncedQuery}"
</li>
) : (
suggestions.map((country, index) => (
<SuggestionItem
key={country.cca2}
id={`suggestion-${index}`}
country={country}
query={debouncedQuery}
isActive={index === activeIndex}
onSelect={handleSelect}
onMouseEnter={() => setActiveIndex(index)}
/>
))
)}
</ul>
)}
{/* Live region for screen reader announcements */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcementText}
</div>
</div>
);
}
Usage example
// App.tsx
import { Autocomplete } from "./components/Autocomplete/Autocomplete";
import type { Country } from "./components/Autocomplete/types";
function App() {
const handleSelect = (country: Country) => {
console.log("Selected:", country.name.common);
console.log("Capital:", country.capital?.[0] ?? "N/A");
console.log("Population:", country.population.toLocaleString());
};
return (
<main style={{ padding: "2rem", maxWidth: "600px", margin: "0 auto" }}>
<h1>Country Search</h1>
<p>Start typing to search for a country.</p>
<Autocomplete
placeholder="Search countries..."
minChars={2}
debounceMs={300}
maxSuggestions={8}
onSelect={handleSelect}
/>
</main>
);
}
export default App;
Performance Optimization
Our component already works well, but here are four optimizations that matter at scale.
1. React.memo for suggestion items
We already wrapped SuggestionItem in React.memo. This is important because when the user presses an arrow key, only two items need to re-render: the previously active item and the newly active item. Without memo, all 8+ items would re-render on every keystroke.
You can verify this works by adding a console log inside SuggestionItem:
export const SuggestionItem = memo(function SuggestionItem(props: SuggestionItemProps) {
console.log("Rendering:", props.country.name.common);
// ...
});
With memo, you should see only 2 logs per arrow key press, not 8.
2. useCallback for handlers
We wrapped handleChange, handleSelect, and handleKeyDown in useCallback. This ensures the functions maintain referential identity across renders, which in turn allows memo on child components to work correctly.
Without useCallback on handleSelect, every render creates a new function reference, and SuggestionItem’s memo wrapper becomes useless because onSelect is always “new.”
3. Virtualized lists with react-window
If your autocomplete could return hundreds of suggestions, rendering all of them into the DOM is wasteful. Libraries like react-window render only the items visible in the viewport.
// Example with react-window (conceptual)
import { FixedSizeList } from "react-window";
<FixedSizeList
height={320}
itemCount={suggestions.length}
itemSize={48}
width="100%"
>
{({ index, style }) => (
<div style={style}>
<SuggestionItem
country={suggestions[index]}
query={debouncedQuery}
isActive={index === activeIndex}
onSelect={handleSelect}
onMouseEnter={() => setActiveIndex(index)}
id={`suggestion-${index}`}
/>
</div>
)}
</FixedSizeList>
When to use this: If you routinely show more than 50 suggestions, virtualization helps. For 8-10 suggestions, the overhead of a virtualization library is not worth it.
4. useDeferredValue for non-blocking updates
React 18 introduced useDeferredValue, which tells React that a value update can be deferred in favor of more urgent work (like keeping the input responsive).
import { useDeferredValue } from "react";
// Inside the component:
const deferredSuggestions = useDeferredValue(suggestions);
const isStale = deferredSuggestions !== suggestions;
// Use deferredSuggestions for rendering:
{deferredSuggestions.map((country, index) => (
<SuggestionItem
key={country.cca2}
// ...props
/>
))}
// Optionally dim the list while stale results are showing:
<ul
className="autocomplete-dropdown"
style={{ opacity: isStale ? 0.7 : 1 }}
>
This is most useful when filtering a large local dataset. If you are fetching from a remote API with debouncing, useDeferredValue adds little benefit because the network latency already creates a natural delay.
Testing the Component
Thorough testing ensures the component works correctly and continues to work as you modify it. Here are tests using React Testing Library and Vitest.
Setup
npm install -D @testing-library/react @testing-library/user-event @testing-library/jest-dom vitest jsdom
Unit tests
// Autocomplete.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, describe, it, expect, beforeEach } from "vitest";
import { Autocomplete } from "./Autocomplete";
// Mock the fetch API
const mockCountries = [
{
name: { common: "France", official: "French Republic" },
cca2: "FR",
flags: { svg: "/flags/fr.svg", png: "/flags/fr.png" },
population: 67390000,
region: "Europe",
capital: ["Paris"],
},
{
name: { common: "Finland", official: "Republic of Finland" },
cca2: "FI",
flags: { svg: "/flags/fi.svg", png: "/flags/fi.png" },
population: 5541000,
region: "Europe",
capital: ["Helsinki"],
},
];
beforeEach(() => {
vi.restoreAllMocks();
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(mockCountries),
});
});
describe("Autocomplete", () => {
it("renders the search input", () => {
render(<Autocomplete />);
expect(
screen.getByRole("combobox")
).toBeInTheDocument();
});
it("does not show suggestions below minChars", async () => {
const user = userEvent.setup();
render(<Autocomplete minChars={2} />);
await user.type(screen.getByRole("combobox"), "f");
await waitFor(() => {
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
});
it("shows suggestions after typing enough characters", async () => {
const user = userEvent.setup();
render(<Autocomplete minChars={2} debounceMs={0} />);
await user.type(screen.getByRole("combobox"), "fi");
await waitFor(() => {
expect(screen.getByRole("listbox")).toBeInTheDocument();
expect(screen.getAllByRole("option")).toHaveLength(2);
});
});
it("calls onSelect when a suggestion is clicked", async () => {
const onSelect = vi.fn();
const user = userEvent.setup();
render(<Autocomplete onSelect={onSelect} minChars={2} debounceMs={0} />);
await user.type(screen.getByRole("combobox"), "fi");
await waitFor(() => {
expect(screen.getByRole("listbox")).toBeInTheDocument();
});
await user.click(screen.getByText("France"));
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({
name: { common: "France", official: "French Republic" },
})
);
});
it("closes dropdown on Escape", async () => {
const user = userEvent.setup();
render(<Autocomplete minChars={2} debounceMs={0} />);
await user.type(screen.getByRole("combobox"), "fi");
await waitFor(() => {
expect(screen.getByRole("listbox")).toBeInTheDocument();
});
await user.keyboard("{Escape}");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
});
Testing keyboard navigation
describe("Keyboard navigation", () => {
it("navigates suggestions with arrow keys", async () => {
const user = userEvent.setup();
render(<Autocomplete minChars={2} debounceMs={0} />);
await user.type(screen.getByRole("combobox"), "fi");
await waitFor(() => {
expect(screen.getByRole("listbox")).toBeInTheDocument();
});
// Press down arrow -- first item should be active
await user.keyboard("{ArrowDown}");
const options = screen.getAllByRole("option");
expect(options[0]).toHaveAttribute("aria-selected", "true");
expect(options[1]).toHaveAttribute("aria-selected", "false");
// Press down arrow again -- second item should be active
await user.keyboard("{ArrowDown}");
expect(options[0]).toHaveAttribute("aria-selected", "false");
expect(options[1]).toHaveAttribute("aria-selected", "true");
});
it("wraps around when arrowing past the last item", async () => {
const user = userEvent.setup();
render(<Autocomplete minChars={2} debounceMs={0} />);
await user.type(screen.getByRole("combobox"), "fi");
await waitFor(() => {
expect(screen.getAllByRole("option")).toHaveLength(2);
});
// Arrow down twice to reach the end, then once more to wrap
await user.keyboard("{ArrowDown}");
await user.keyboard("{ArrowDown}");
await user.keyboard("{ArrowDown}");
const options = screen.getAllByRole("option");
expect(options[0]).toHaveAttribute("aria-selected", "true");
});
it("selects a suggestion with Enter", async () => {
const onSelect = vi.fn();
const user = userEvent.setup();
render(<Autocomplete onSelect={onSelect} minChars={2} debounceMs={0} />);
await user.type(screen.getByRole("combobox"), "fi");
await waitFor(() => {
expect(screen.getByRole("listbox")).toBeInTheDocument();
});
await user.keyboard("{ArrowDown}");
await user.keyboard("{Enter}");
expect(onSelect).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
});
Testing debounce behavior
describe("Debounce", () => {
it("does not fetch on every keystroke", async () => {
const user = userEvent.setup();
render(<Autocomplete minChars={2} debounceMs={300} />);
// Type quickly -- should not trigger fetch for each keystroke
await user.type(screen.getByRole("combobox"), "finland");
// At this point, fetch should not have been called yet
// (or only for the debounced final value)
expect(global.fetch).not.toHaveBeenCalled();
// Wait for debounce to settle
await waitFor(
() => {
expect(global.fetch).toHaveBeenCalledTimes(1);
},
{ timeout: 500 }
);
});
it("cancels pending debounce when input is cleared", async () => {
const user = userEvent.setup();
render(<Autocomplete minChars={2} debounceMs={300} />);
await user.type(screen.getByRole("combobox"), "fi");
await user.clear(screen.getByRole("combobox"));
// Wait past the debounce delay
await new Promise((r) => setTimeout(r, 400));
// Fetch should not have been called because the input was cleared
// before the debounce fired (the cleared value is below minChars)
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
});
Production Considerations
Before shipping this component, consider these real-world concerns.
Rate limiting
The REST Countries API is free and does not require authentication, but most production APIs enforce rate limits. Protect against hitting those limits:
// Simple rate limiter
class RateLimiter {
private timestamps: number[] = [];
constructor(
private maxRequests: number,
private windowMs: number
) {}
canMakeRequest(): boolean {
const now = Date.now();
this.timestamps = this.timestamps.filter(
(t) => now - t < this.windowMs
);
if (this.timestamps.length >= this.maxRequests) {
return false;
}
this.timestamps.push(now);
return true;
}
}
// Allow 10 requests per second
const limiter = new RateLimiter(10, 1000);
async function fetchCountriesWithRateLimit(
query: string
): Promise<Country[]> {
if (!limiter.canMakeRequest()) {
console.warn("Rate limit reached, skipping request");
return [];
}
return fetchCountries(query);
}
Combined with our 300ms debounce and caching, the rate limiter acts as a safety net for edge cases.
Error handling and retry
Network requests fail. Handle errors gracefully with an exponential backoff retry:
async function fetchWithRetry(
url: string,
retries: number = 2,
delay: number = 500
): Promise<Response> {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const response = await fetch(url);
if (response.ok || response.status === 404) {
return response;
}
// Only retry on server errors (5xx)
if (response.status < 500) {
return response;
}
} catch (error) {
if (attempt === retries) throw error;
}
// Wait before retrying, with exponential backoff
await new Promise((resolve) =>
setTimeout(resolve, delay * Math.pow(2, attempt))
);
}
throw new Error("Max retries exceeded");
}
Important: Only retry on network errors and 5xx server errors. Never retry on 4xx client errors — if the server says “bad request,” sending the same request again will not fix it.
Mobile touch support
Our component already works on mobile because we use click and mousedown events, which mobile browsers translate from touch events. However, there are a few mobile-specific improvements worth considering:
-
Input type: Use
type="search"instead oftype="text"on mobile. This gives iOS users a “Search” button on the keyboard instead of “Return.” -
Viewport meta: Ensure your page has
<meta name="viewport" content="width=device-width, initial-scale=1">to prevent the input from zooming in on iOS when the font size is below 16px. -
Touch target size: The WCAG 2.2 minimum touch target size is 24x24px, but Apple recommends 44x44px. Our suggestion items at 48px tall exceed both requirements.
-
Virtual keyboard offset: When the virtual keyboard opens on mobile, it can obscure the dropdown. Consider scrolling the input into view:
const handleFocus = useCallback(() => {
// On mobile, scroll the input into view after keyboard opens
setTimeout(() => {
inputRef.current?.scrollIntoView({
behavior: "smooth",
block: "center",
});
}, 300); // Wait for virtual keyboard animation
}, []);
Server-side rendering considerations
If you use Next.js, Remix, or any SSR framework, the autocomplete component needs no special handling because it is entirely client-side (no DOM access during render except through refs, which are null on the server).
However, if you want the input to render during SSR for SEO or initial paint performance:
-
The initial render (server-side) will show just the input with no dropdown. This is correct — there are no suggestions until the user types.
-
The
useEffecthooks will not run on the server. They only run after hydration on the client, which is when the user can interact with the component. -
If you are using Next.js App Router, mark the component with
"use client"at the top of the file:
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
// ... rest of the component
- Avoid
windowordocumentaccess in render. Our component only accesses the DOM insideuseEffectand event handlers, which is SSR-safe.
Error boundary
Wrap the autocomplete in an error boundary so a fetch failure does not crash the entire page:
import { Component, type ReactNode } from "react";
interface ErrorBoundaryProps {
fallback: ReactNode;
children: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
}
class AutocompleteErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
state: ErrorBoundaryState = { hasError: false };
static getDerivedStateFromError(): ErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error) {
console.error("Autocomplete error:", error);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
// Usage:
<AutocompleteErrorBoundary
fallback={<input type="text" placeholder="Search (temporarily unavailable)" />}
>
<Autocomplete onSelect={handleSelect} />
</AutocompleteErrorBoundary>
The fallback renders a plain input so users can still see the search field even if the autocomplete logic fails.
Wrapping Up
Here is a summary of everything we built, step by step:
- Basic input — Controlled component with
useState. - API integration — Fetch suggestions with race condition handling via the
cancelledflag pattern. - Text highlighting — Regex-based
<mark>wrapping with proper escaping. - Debouncing — Custom
useDebouncehook that reduces API calls by 70-80%. - Keyboard navigation — Arrow keys, Enter, Escape, Tab, with wrap-around.
- Accessibility — Full WAI-ARIA combobox pattern with live region announcements.
- Click outside — Custom
useClickOutsidehook with proper cleanup. - Caching —
useRef+Mapwith LRU-style eviction. - Loading/empty states — Spinner, “no results,” and minimum character threshold.
- Performance —
React.memo,useCallback, virtualization guidance,useDeferredValue. - Testing — React Testing Library tests for rendering, keyboard, selection, and debounce.
- Production hardening — Rate limiting, error retry, mobile support, SSR safety, error boundaries.
The complete component is around 200 lines of TypeScript. It has zero external dependencies (beyond React itself), full keyboard and screen reader support, and handles the edge cases that trip up most implementations.
If you want to skip the build-from-scratch approach for your next project, the two libraries we recommend are:
- Downshift by Kent C. Dodds — headless, accessible, highly customizable. Use this if you want full control over rendering.
- Radix UI Combobox — unstyled, accessible primitives. Use this if you are already in the Radix ecosystem.
Both handle the keyboard navigation and ARIA patterns we built manually, so you can focus on styling and business logic. But now that you have built it from scratch, you understand exactly what those libraries are doing under the hood — and you can debug them when something goes wrong.
Further Reading
Frequently Asked Questions
How do I build an autocomplete search in React?
Create a component with useState for the query and results, implement an onChange handler that filters or fetches data as the user types, add debouncing to limit API calls, render a dropdown of suggestions, and handle keyboard navigation (arrow keys + Enter) for accessibility.
Should I debounce autocomplete API calls in React?
Yes. Without debouncing, every keystroke triggers an API request, which wastes bandwidth and can overwhelm your server. A 300ms debounce delay is the standard recommendation — it feels instant to users while reducing API calls by 70-80%.
How do I make a React autocomplete accessible?
Use ARIA roles (combobox, listbox, option), manage aria-activedescendant for keyboard focus, support arrow key navigation, handle Enter to select, Escape to close, and ensure screen readers announce the number of available suggestions.
What libraries exist for React autocomplete?
Popular options include Downshift (headless, accessible), React Select (feature-rich), Radix UI Combobox (unstyled primitives), and Algolia InstantSearch (for Algolia backends). For learning purposes, building from scratch is recommended.
Explore More
Related Articles
- Fake SOC 2 and ISO 27001 Certifications Are Spreading Across Dev Tools
- Input vs Output vs Reasoning Tokens Cost - LLM Pricing Explained
- MISRA C:2012 Rules with Examples - Complete Guide for Embedded Developers
- Parallel Tool Calling in LLM Agents - Complete Guide with Code Examples
- ripgrep vs grep: Performance Benchmarks and Why AI Agents Use rg
Free Newsletter
Stay ahead with AI dev tools
Weekly insights on AI code review, static analysis, and developer productivity. No spam, unsubscribe anytime.
Join developers getting weekly AI tool insights.
Related Articles
Fake SOC 2 and ISO 27001 Certifications Are Spreading Across Dev Tools
A recent investigation alleges that compliance automation platform Delve manufactured false SOC 2 and ISO 27001 certifications for startups. Here is what developers should know and how to verify the tools you trust.
March 20, 2026
guideInput vs Output vs Reasoning Tokens Cost - LLM Pricing Explained
Understand the difference between input, output, and reasoning tokens in LLMs. Compare pricing for GPT-4o, Claude, Gemini, and o3 models with cost optimization tips for AI code review.
March 20, 2026
guideMISRA C:2012 Rules with Examples - Complete Guide for Embedded Developers
Learn MISRA C:2012 rules with practical C code examples. Covers mandatory, required, and advisory rules, violations vs compliant code, and the best MISRA compliance tools.
March 20, 2026