Handling Debounced Input with Change Events in JavaScript

Learn how to manage debounced input in JavaScript using change events while separating logic for better reuse and maintainability.

When building web applications or handling real-time data, things don’t always go as smoothly as we’d like. Events can fire out of order, certain actions might trigger more than once, and performance can take a hit if we’re not careful. Ever clicked a button and seen your function run five times? Or tried to listen to scrolling events only to watch your app struggle to keep up? You’re not alone.

How Debounce works

This is where event flow control comes in—and one of the most powerful tools for this job is debounce.

What is Debounce?

Debounce is a technique used to limit how often a function gets called. It’s perfect for situations where a function might otherwise run way too often in a short period of time. Think of:

  • Preventing a search function from running on every keypress.
  • Avoiding multiple form submissions from rapid button clicks.
  • Reducing the number of resize or scroll events your code has to handle.

These are all signs that a debounce (or a similar technique like throttle) is probably what you need.

How Debounce works

At its core, debounce creates a delay between function calls. Instead of firing the function right away, it waits a certain amount of time to see if it gets triggered again. If it does, the timer resets. Only when the function hasen’t been triggered for a set period will it finally run.

function debounce(fn, delay) {
  let timeoutId;
  return function (...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn.apply(this, args), delay);
  };
}

Clear aware Debounce context

<html>
<body>
  <input id="testing" type="text">

  <script>
    function Debounced(el, changeCb, inputCb, timeout) {
      let timer;
      function debounce(func, timeout = 300){
        return (...args) => {
          clearTimeout(timer);
          timer = setTimeout(() => { func.apply(this, args); }, timeout);
        };
      }

      const debouncedInput = debounce(inputCb, timeout);

      let state = 'inital';
      const isInputting = () => state === 'inputting';

      el.addEventListener('change', (e) => {
        if (isInputting()) {
          clearTimeout(timer);
        }
        state = 'idle';
        changeCb(e);
      });
      el.addEventListener('input', (e) => {
        state = 'inputting';
        debouncedInput(e);
      });
    }


    const el = document.getElementById('testing');

    const onChange = () => {
      console.log('onChange')
    };
    const onInput = () => {
      console.log('onInput');
    };
  
    const timeout = 2e3; // 2 seconds
    Debounced(el, onChange, onInput, timeout);
  </script>
</body>
</html>
Example

This method couples the debounce function implementation with the elements (`Debounced``) class, which is not great. So, lets move it outside so it can be imported, allowing it to be code-split and reused elsewhere without any code duplication.

We need to ensure that the timeout timer value is assigned within the inner function of debounce , while still allowing this value to persist across the life-cycle of the Debounced Class.

Requests

function debounce(func, context = {timer: void 0}, timeout = 300){
  return (...args) => {
    clearTimeout(context.timer);
    context.timer = setTimeout(() => { func.apply(this, args); }, timeout);
  };
}

const functionToCall = () => console.log('function called');
const request = {timer: void 0};
const debounced = debounce(functionToCall, request);
debounced();

Improvements

We can even create a cleaner API for canceling, this way we don’t need to worry about calling the clearTimeout function. Instead, call .clear().

API

function CreateContext() {
  let timer = void 0;
  return {
    setTimer(callback) {
      timer = setTimeout(callback)
    },
    clear() {
      if (!timer) return;
      timer = clearTimeout(timer);
    },
    //active() {
    // // if you want
    //  return Boolean(timer);
    //}
  }
}

function debounce2(func, context = CreateContext(), timeout = 300){
  return (...args) => {
    context.clear();
    context.setTimer(func.bind(null, ...args), timeout);
  };
}

const functionToCall = () => console.log('function called');
const request = CreateContext();
const debounced = debounce2(functionToCall, request);
debounced();

// after the first successful debounce
setTimeout(function() {
  debounced(); // call again
  // cancel the new call
  request.clear();
}, 400);