Skip to main content

Command Palette

Search for a command to run...

useDebounceCallback hook explained

Timeouts, Closures, JavaScript and references.

Updated
4 min read
useDebounceCallback hook explained
T

Just a guy who loves to write code and watch anime.

Introduction

In my last side project, I needed to debounce a callback. I ended up writing a custom hook to do so. In this post, I'll explain how it works. The tricky part is grasping why we need the callback ref.

import { useCallback, useEffect, useRef } from 'react'

export function useDebounceCallback<Args extends Array<unknown>>(
  callback: (...args: Args) => void,
  delay: number
): (...args: Args) => void {
  // timeoutRef's job is to store the timeout id
  const timeoutRef = useRef<NodeJS.Timeout | null>(null)

  // callbackRef's job is to store the callback function to be called in the timeout
  const callbackRef = useRef(callback)

  useEffect(() => {
    callbackRef.current = callback
  }, [callback])

  const debouncedCallback = useCallback(
    (...args: Args) => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current)
      }

      timeoutRef.current = setTimeout(() => {
        callbackRef.current(...args)
      }, delay)
    },
    [delay]
  )

  useEffect(() => {
    // cleanup
    // When unmounting, clear the timeout
    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current)
      }
    }
  }, [])

  return debouncedCallback
}

What is a closure?

First, we need to understand what a closure is.

A closure is when a function "remembers" the variables from its outer scope, even when executed later. It captures these variables at the time the function is created. This is a fundamental feature of JavaScript.

Here is an example:

function outer() {
  let x = 1;
  return function inner() {
    console.log(x); // Remembers x from outer scope
  };
}

const fn = outer();
fn(); // Prints 1

Okay? Why does it matter?

Here is a clearer example why closures are important:

let x = 1;
const fn = () => console.log(x); // Captures x=1
x = 2;
setTimeout(fn, 1000); // Will print 1, not 2

When calling fn later, it will still remember the value of x from the outer scope. Even if x is changed later.

Closures and React

Here is a basic React example:

function MyComponent() {
  const [count, setCount] = useState(0);

  // This function captures count=0 when component first renders
  const logCount = () => console.log(count);

  // Even if count changes to 1, the timeout will log 0
  setTimeout(logCount, 1000);
}

A more interesting example:

function MyComponent() {
  const [count, setCount] = useState(0);

  const debouncedLog = useCallback(() => {
    // This creates a closure over count when useCallback runs
    setTimeout(() => {
      console.log(count); // Will always log the count from when useCallback ran
    }, 1000);
  }, []); // Empty deps means callback never updates
}

Here, I hope it's starting to click. If count was e.g. 5 at the time useCallback was run, it will always log 5. You can tell setTimeout to execute an hour later. It's still going to log 5 (even if the value is 1000 by then).

Why? Because that's what it "remembers".

The callback ref pattern

const debouncedCallback = useCallback(
  (...args: Args) => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }

    timeoutRef.current = setTimeout(() => {
      // What's going on here?
      callbackRef.current(...args);
    }, delay);
  },
  [delay]
);

Looking back the previous examples, you might go "this is impossible to update".

Well, it's not. Refs are just mutable objects. In this case, the closure will capture callbackRef's reference.

So when callbackRef.current is called, it looks at the REFERENCE of callbackRef and THEN gets redirected to the current property of callbackRef.

The current field ALWAYS holds the latest callback.

useEffect(() => {
  callbackRef.current = newCallback;
  // In memory: same callbackRef object, but now new `current` field { current: newCallback }
}, [callback]);

To be very explicit, setTimeout works as it always does. It creates a closure over callbackRef's reference.

But Tiger, why is it not closing over .current field?

That's a good question.

The key is in how the code is written.

setTimeout(() => {
  callbackRef.current(...args); // This is ONE expression
}, delay);

// It's NOT the same as (this would be wrong!):
const currentCallback = callbackRef.current; // Accessing .current NOW
setTimeout(() => {
  currentCallback(...args); // Using captured value (currentCallback)
}, delay);

The second bit here GETS the value of callbackRef.current before the closure is created. Therefore, a closure will be created over this specific value.

When you write callbackRef.current(...args), you're writing ONE expression that:

  1. First gets the reference (callbackRef)

  2. Then follows that reference

  3. Then gets the .current property and uses it

  4. All at execution time!

Recap with final example

// Memory model example:
const callbackRef = { current: originalCallback }; // Object in memory
// setTimeout's closure captures this ENTIRE object reference

// Later:
callbackRef.current = newCallback; // SAME object, new value inside
// When timeout runs, follows same reference, gets new value

// VS wrong way (getting value and using it):
const currentCallback = callbackRef.current; // Captures value immediately
// Later changes to callbackRef.current won't affect this
// Because setTimeout closed over this specific value during time of its creation

By Value vs By Reference

If you've a hard time understanding the concept of "reference", I recommend reading this: By value vs By Reference in JavaScript.

R

let x = 1; const fn = () => console.log(x); // Captures x=1 x = 2; setTimeout(fn, 1000); // Will print 1, not 2

this will print 2 not 1 i have tried it any explaination why?