Building an AutoCompleter is another common interview question that usually comes with multiple sub-tasks.
The task of building an Autocompleter with vanilla js from scratch can be split into the following:
- Search function
- Mock API
- Handling delayed responses
- Debounce
We'll set up the mock API first,
// generate random response string
const randomStr = () => Math.random().toString(36).substring(2, 8);
// generate a random value within a range
// for varying response delays
const randomInRange = (min = 0, max = 5) =>
min + Math.floor(Math.random() * max);
const mockApi = (searchText, delay = 1000 * randomInRange(0, 3)) => {
const results = [];
if (searchText === "") {
return Promise.resolve(results);
}
for (let i = 0; i < randomInRange(3, 5); i++) {
results.push(`${searchText} - ${randomStr()}`);
}
return new Promise((resolve, reject) => {
window.setTimeout(() => {
randomInRange(0, 25) === 24
? reject("Internal server error")
: resolve(results);
}, delay);
});
};
The HTML part
<div>
<input id="searchbox" />
<div id="resultbox" />
</div>
The AutoCompleter will accept two parameters, the input field to listen to and a callback to pass the results.
It will add a keyup event that will call the mockApi, wait for results, and once done it will call the callback function with the results.
One common scenario is handling out of order responses. It could be that search#1 came back after 3 sec while search#2 responded within 1 sec. For this, we have to keep track of the latest query using a closure or check the text in the search field before executing callback.
function AutoCompleter(searchBox, doneCallback) {
let latestQuery = "";
// search action
async function triggerSearch(event) {
try {
const text = event.target.value;
latestQuery = text; // keep track of latest search text
const result = await mockApi(text);
// handle delays
if (latestQuery === text) {
doneCallback(result);
}
} catch (err) {
console.log("api error");
}
}
// add event listener
searchBox.addEventListener("keyup", triggerSearch);
// way to remove the listener
return {
clear: () => {
searchBox.removeEventListener("keyup", triggerSearch);
}
};
}
Triggering a search on every keypress could result in several unwanted calls, it's better to trigger search only when the user pauses typing. Read more about debouncing and throttling here
function debouce(fn, delay=250) {
let timeoutId = null;
return (...args) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
fn(...args);
}, delay);
};
}
Use the debounced function for searching
const debouncedSearch = debouce(triggerSearch, 250);
// add event listener
searchBox.addEventListener("keyup", debouncedSearch);
Call the AutoCompleter
const searchInstance = new AutoCompleter(document.getElementById("searchbox"), (output) => {
document.getElementById("resultbox").innerText = output;
});
// searchInstance.clear();
Checking for the latest query solves the problem of delayed responses but a possible add-on question to the main problem will be to implement a generic solution to the problem of getting the latest promise.
function latestPromise(fn) {
let latest = null;
return (...args) => {
latest = fn(...args); // update the latest promise
return new Promise(async (resolve, reject) => {
const current = latest;
try {
const res = await current;
// check before resolving
current === latest ? resolve(res) : console.log("skip");
} catch (err) {
reject(err);
}
});
};
}
Few quick tests
const wrappedMockApi = latestPromise(mockApi);
async function searchAction(text, delay) {
const res = await wrappedMockApi(text, delay);
console.log("res", res);
}
searchAction("search-1", 1000);
searchAction("search-2", 400);
searchAction("search-3", 200);
searchAction("search-4", 100);
// response log will show value only for search-4
// reminaining will be skipped
In most cases, the mock API and HTML will be part of the boilerplate and there'll be about 40 minutes to write the rest of the code.
The working code can be viewed here
Top comments (0)