DEV Community

Cover image for Build an AutoCompleter
Kailash Sankar
Kailash Sankar

Posted on

Build an AutoCompleter

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);
  });
};
Enter fullscreen mode Exit fullscreen mode

The HTML part

      <div>
        <input id="searchbox" />
        <div id="resultbox" />
      </div>
Enter fullscreen mode Exit fullscreen mode

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);
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

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);
  };
}
Enter fullscreen mode Exit fullscreen mode

Use the debounced function for searching

 const debouncedSearch = debouce(triggerSearch, 250);

 // add event listener
 searchBox.addEventListener("keyup", debouncedSearch);
Enter fullscreen mode Exit fullscreen mode

Call the AutoCompleter

const searchInstance = new AutoCompleter(document.getElementById("searchbox"), (output) => {
  document.getElementById("resultbox").innerText = output;
});

// searchInstance.clear();
Enter fullscreen mode Exit fullscreen mode

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);
      }
    });
  };
}
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Oldest comments (0)