Debouncing in React: Understanding State Immutability

Maybe you have once been surprised by the state values inside your effects (api call, logs, etc.) ? It can happen when we don’t fully grasp the concept of immutability in functional components. While you might know you can change the state with setState, there’s a key detail: the state variable itself never gets modified directly within the component function.
React actually creates a completely new instance of your component function whenever the state changes. This means the props and state available inside the function remain constant for a single render cycle, as phrased by Dan Abramov in his insightful article on useEffect and immutability.

It’s actually easy to make a mistake even when you are aware of it. This article will demonstrate this principle and explore its implications, particularly when dealing with user interactions like debounced input handling.

Demonstration of state immutability

Here’s a simple component that contains a text input and makes an API call when the input changes. We fake the API call with a simple console log for the sake of the example.

import React, { useState } from 'react';

export function App() {
  const [value, setValue] = useState('init');
  const onChange = (event) => {
    setValue(event.target.value);
    makeApiCall(value); // Wrong ! -> value is still the same as it is on top !
  }

  return (
    <div>
      <label htmlFor="value">Try modifying the value</label>
      <input id="value" value={value} onChange={onChange} />
    </div>
  );
}

const makeApiCall = (value) => {
  console.log(value);
}

Output when typing “123”: 

init
1
12

As we see, the logs are lagging behind the update, because the value is only updated on the next render. 

2 ways to correct it:

  • replace makeApiCall(value); with makeApiCall(event.target.value);
    We know in this example that they are the same.
  • Rely on useEffect. Maybe in your case the state is actually owned by a parent component which might have altered the value (like removing accents or spaces for example):
import React, { useEffect, useState } from 'react';

export function App() {
  const [value, setValue] = useState('init');
  const onChange = (event) => {
    setValue(event.target.value); 
  }

  useEffect(() => {
    makeApiCall(value); // note that this will also be triggered the first time the component is rendered, when value is “init”
  }, [value])

  return (
    <div>
      <label htmlFor="value">Try modifying the value</label>
      <input id="value" value={value} onChange={onChange} />
    </div>
  );
}

const makeApiCall = (value) => {
  console.log(value);
}
init
1
12
123

Adding Complexity: Debouncing

Armed with the knowledge that states are immutable within a single render of a React component, and causes a new instance of the function to be created, we can immediately see that the following snippet is wrong:

import React, { useState } from 'react';

export function App() {
  const [value, setValue] = useState('');
  let timeOutId;
  const onChange = (event) => {
    setValue(event.target.value);
    clearTimeout(timeOutId);
    timeoutID = setTimeout(makeApiCall, 1000*2, value);
  }

  return (
    <div>
      <label htmlFor="value">Try modifying the value</label>
      <input id="value" value={value} onChange={onChange} />
    </div>
  );
}

const makeApiCall = (value) => {
  console.log(value);
}

Here’s the output writing “123”

1
12

As shown before, the value stays the same after the setValue. That’s why we are always 1 update late.
And of course, the timeOutId is not shared between renders (which create a new function instance). As a result clearTimeout never actually clears it because timeOutId ends up always undefined.
We can fix the value issue by using an Effect or by passing event.target.value. But we also need to share the timeOutId between renders. We might simply be tempted to put it in a state like so:

export function App() {
  const [value, setValue] = useState('');
  const [timeOutId, setTimeOutId] = useState('');

  const onChange = (event) => {
    setValue(event.target.value);
    clearTimeout(timeOutId);
    setTimeOutId(setTimeout(makeApiCall, 1000*2, event.target.value));
  }

  return (
    <div>
      <label htmlFor="value">Try modifying the value</label><br />
      <input id="value" value={value} onChange={onChange} />
    </div>
  );
}

Actually, this works. But using a state to store the timeOutId causes React to re-render our component. A more optimal code would be to replace useState with useRef: “useRef is a React Hook that lets you reference a value that’s not needed for rendering.”. In other words, it allows us to share a mutable variable between instances of the function, and has no impact on renders.

export function App() {
  const [value, setValue] = useState('');
  const timeOutId = useRef();

  const onChange = (event) => {
    if(timeOutId.current) {
      clearTimeout(timeOutId.current);
    }
    setValue(event.target.value);
  }

  return (
    <div className='App'>
      <label htmlFor="value">Try modifying the value</label>
      <input id="value" value={value} onChange={onChange} />
    </div>
  );
}

The same code, but extracting the debouncing logic out of the component.

export function App() {
  const [value, setValue] = useState('');
  const debouncedApiCall = useDebounce(makeApiCall, 2000);

  useEffect(() => {
    debouncedApiCall(value);
  }, [value, debouncedApiCall]);

  const onChange = (event) => {
    setValue(event.target.value);
  }

  return (
    <div className='App'>
      <label htmlFor="value">Try modifying the value</label>
      <input id="value" value={value} onChange={onChange} />
    </div>
  );
}

const useDebounce = (fn, ms) => {
  const timeOutId = useRef();
  const onChange = (...params) => {
    if(timeOutId.current) {
      clearTimeout(timeOutId.current);
    }
    timeOutId.current = setTimeout(fn, ms, ...params);
  };

  return onChange;
}

const makeApiCall = (value) => {
  console.log(value);
}

You could also just rely on useDebounce from the react-use library.

Pitfall: debouncing with use-query

When using use-query, typically we have a hook like so:

const useTodoQuery = (todoTitle) => { 
   return useQuery({
    queryKey: ['todos', todoTitle],
    queryFn: () =>
      fetch(`https://api.domain/todos?title=${todoTitle}`).then((res) =>
        res.json(),
      ),
  })
};

This poses an issue if we try to use this directly in our component:

export function App() {
  const [value, setValue] = useState('');

  // we cannot do the following because of the rules of hooks
  const debouncedApiCall = useDebounce(useTodoQuery, 2000);

  useEffect(() => {
    debouncedApiCall(value);
  }, [value, debouncedApiCall]);

  ...
}

The problem we have here is that the useQuery hook, like any hook, must be called in the main component body (not in a callback). Doing this immediately triggers an API call.

What we want instead is a hook that returns a function that we can pass to our useDebounce hook.
Instead of forcing a square peg into a round hole, we can write such a hook like so:

const useTodoLazyQuery = () => { 
   const queryClient = useQueryClient();
  
   return (todoTitle) => (
    queryClient.fetchQuery(
      {
        queryKey: ['todos', todoTitle],
        queryFn: () =>
          fetch(`https://api.domain/todos?title=${todoTitle}`).then((res) =>
            res.json(),
          ),
      }
    )
   )
};

And we can now use it in our component

export function App() {
  const [value, setValue] = useState('');
  const fetchTodos = useTodoLazyQuery();
  const debouncedApiCall = useDebounce(fetchTodos, 2000);

  useEffect(() => {
    debouncedApiCall(value);
  }, [value, debouncedApiCall]);

  ...
}