loading...
Cover image for Build an AutoCompleter

Build an AutoCompleter

ksankar profile image Kailash Sankar ・3 min read

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:

  1. Search function
  2. Mock API
  3. Handling delayed responses
  4. 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

Discussion

pic
Editor guide