How to Build a React Autocomplete component
Autocomplete or word completion works so that when the writer writes the first letter or letters of a word, the program predicts one or more possible words as choices. If the word he intends to write is included in the list he can select it.
What will follow is my attempt to build an autocomplete component only using React and custom React hooks.
We would like our autocomplete to do the following:
- Style Input and Option List
- Static source
- Remote source
- Scrollable results
- Keyboard navigation
- Jump to first option
- Jump to last option
- Select option on enter or mouse click
let's create the UI component
1import React, { useRef, useState } from 'react' 2import { HiSearch, HiUser } from 'react-icons/hi' 3import useAutoComplete from './custom-hooks/use-autocomplete' 4 5const Options = [ 6 { value: "1", label: "John" }, 7 { value: "2", label: "Jack" }, 8 { value: "3", label: "Jane" }, 9 { value: "4", label: "Mike" }, 10] 11 12export default function Home() { 13 14 const { bindInput, bindOptions, bindOption, isBusy, suggestions, selectedIndex} = useAutoComplete({ 15 onChange: value => console.log(value), 16 source: (search) => Options.filter(option => new RegExp(`^${search}`, "i").test(option.label)) 17 }) 18 19 return ( 20 <div className="p-2 border" > 21 <div className="flex items-center w-full"> 22 <HiSearch /> 23 <input 24 placeholder='Search' 25 className="flex-grow px-1 outline-none" 26 {...bindInput} 27 /> 28 {isBusy && <div className="w-4 h-4 border-2 border-dashed rounded-full border-slate-500 animate-spin"></div>} 29 </div> 30 <ul {...bindOptions} className="w-[300px] scroll-smooth absolute max-h-[260px] overflow-x-hidden overflow-y-auto" > 31 { 32 suggestions.map((_, index) => ( 33 <li 34 className={`flex items-center h-[40px] p-1 hover:bg-slate-300 ` + (selectedIndex === index && "bg-slate-300")} 35 key={index} 36 {...bindOption} 37 > 38 <div className="flex items-center space-x-1"> 39 <HiUser /> 40 <div>{suggestions[index].label}</div> 41 </div> 42 </li> 43 )) 44 } 45 </ul> 46 </div> 47 ) 48} 49
As can be seen, all the styling and layout is done here. The functionality of the input, option list (ul), and options (li) are delegated to the custom hook through the objects bindInput, bindOptions, and bindOption
1import React, { useRef, useState } from 'react' 2 3const KEY_CODES = { 4 "DOWN": 40, 5 "UP": 38, 6 "PAGE_DOWN": 34, 7 "ESCAPE": 27, 8 "PAGE_UP": 33, 9 "ENTER": 13, 10} 11 12 13export default function useAutoComplete({ delay = 500, source, onChange }) { 14 15 const [myTimeout, setMyTimeOut] = useState(setTimeout(() => { }, 0)) 16 const listRef = useRef() 17 const [suggestions, setSuggestions] = useState([]) 18 const [isBusy, setBusy] = useState(false) 19 const [selectedIndex, setSelectedIndex] = useState(-1) 20 const [textValue, setTextValue] = useState("") 21 22 function delayInvoke(cb) { 23 if (myTimeout) { 24 clearTimeout(myTimeout) 25 } 26 setMyTimeOut(setTimeout(cb, delay)); 27 } 28 29 function selectOption(index) { 30 if (index > -1) { 31 onChange(suggestions[index]) 32 setTextValue(suggestions[index].label) 33 } 34 clearSuggestions() 35 } 36 37 async function getSuggestions(searchTerm) { 38 if (searchTerm && source) { 39 const options = await source(searchTerm) 40 setSuggestions(options) 41 } 42 } 43 44 function clearSuggestions() { 45 setSuggestions([]) 46 setSelectedIndex(-1) 47 } 48 49 function onTextChange(searchTerm) { 50 setBusy(true) 51 setTextValue(searchTerm) 52 clearSuggestions(); 53 delayInvoke(() => { 54 getSuggestions(searchTerm) 55 setBusy(false) 56 }); 57 } 58 59 60 const optionHeight = listRef?.current?.children[0]?.clientHeight 61 62 function scrollUp() { 63 if (selectedIndex > 0) { 64 setSelectedIndex(selectedIndex - 1) 65 } 66 listRef?.current?.scrollTop -= optionHeight 67 } 68 69 function scrollDown() { 70 if (selectedIndex < suggestions.length - 1) { 71 setSelectedIndex(selectedIndex + 1) 72 } 73 listRef?.current?.scrollTop = selectedIndex * optionHeight 74 } 75 76 function pageDown() { 77 setSelectedIndex(suggestions.length - 1) 78 listRef?.current?.scrollTop = suggestions.length * optionHeight 79 } 80 81 function pageUp() { 82 setSelectedIndex(0) 83 listRef?.current?.scrollTop = 0 84 } 85 86 function onKeyDown(e) { 87 88 const keyOperation = { 89 [KEY_CODES.DOWN]: scrollDown, 90 [KEY_CODES.UP]: scrollUp, 91 [KEY_CODES.ENTER]: () => selectOption(selectedIndex), 92 [KEY_CODES.ESCAPE]: clearSuggestions, 93 [KEY_CODES.PAGE_DOWN]: pageDown, 94 [KEY_CODES.PAGE_UP]: pageUp, 95 } 96 if (keyOperation[e.keyCode]) { 97 keyOperation[e.keyCode]() 98 } else { 99 setSelectedIndex(-1) 100 } 101 } 102 103 return { 104 bindOption: { 105 onClick: e => { 106 let nodes = Array.from(listRef.current.children); 107 selectOption(nodes.indexOf(e.target.closest("li"))) 108 } 109 }, 110 bindInput: { 111 value: textValue, 112 onChange: e => onTextChange(e.target.value), 113 onKeyDown 114 }, 115 bindOptions: { 116 ref: listRef 117 }, 118 isBusy, 119 suggestions, 120 selectedIndex, 121 } 122} 123
Lastly, the autocomplete component will be able to work with a remote source simply by changing the source and optionally setting a delay so that the remote source is only called after keyboard typing delay:
1 const { bindInput, bindOptions, bindOption, isBusy, suggestions, selectedIndex} = useAutoComplete({ 2 onChange: value => console.log(value), 3 delay: 1000, 4 source: async (search) => { 5 try { 6 const res = await fetch(`${process.env.apiBase}/user/search?q=${search}`) 7 const data = await res.json() 8 return data.map(d => ({ value: d._id, label: d.name })) 9 } catch (e) { 10 return [] 11 } 12 } 13 })