37

I am using React Query to fetch data from an API in a React app. I want to implement debounce for better performance, but I'm having trouble getting it to work with useQuery. When I try to wrap my API call in a debounced function, I get an error saying "query function must return a defined value".

Here is the code I am currently using:

    async function fetchProducts() {
        const response = await axios.get(`/api/products?category_id=${category_id}&${searchParams.toString()}&page=${page}`);
        return response.data;
    }

    const debouncedFetchProducts = React.useMemo(
        () => _.debounce(fetchProducts, 500),
        [fetchProducts]
    );

    // The following queries will execute in parallel
    const categoryQuery = useQuery({ queryKey: ['category'], queryFn: fetchCategory, keepPreviousData: true });
    const productsQuery = useQuery({ queryKey: ['products', category_id, sortPrice, minPrice, maxPrice, page, categoryFilters], queryFn: debouncedFetchProducts, keepPreviousData: true, staleTime: 1000 });

When I run this, I get an error saying "query function must return a defined value". I believe this is because the debounced function returns a promise, but useQuery expects an actual value.

I have also tried using useAsync, but I would like to use useQuery because it has built-in caching.

Can someone help me figure out how to implement debounce with useQuery in React Query?

Thank you in advance for your help!

6 Answers 6

59

You can utilize the useDebounce hook to trigger a queryKey update in react-query instead of using the debounce function from the Underscore library.

For example:

const [searchParams, setSearchParams] = useDebounce([category_id, sortPrice, minPrice, maxPrice, page, categoryFilters], 1000)
const productsQuery = useQuery({ queryKey: ['products', ...searchParams], queryFn: fetchProducts, keepPreviousData: true, staleTime: 1000 });

useDebounce is applied to the searchParams array, which includes variables like category_id, sortPrice, minPrice, maxPrice, page, and categoryFilters.

The debounce delay is set to 1000 milliseconds (1 second). The productsQuery then uses the debounced search parameters in its query key, ensuring that the fetchProducts function is only called when the debounced search parameters change.

You can find a working useDebounce example in this codesandbox example

Sign up to request clarification or add additional context in comments.

3 Comments

I'm glad the solution worked for you.
For people using the latest version as of July 2024, replace keepPreviousData: true with placeholderData: (prev) => prev
You can also set placeholderData: keepPreviousData as discussed here: tanstack.com/query/latest/docs/framework/react/guides/…
7

I used the AbortSignal parameter with a sleep() on a useMutation to replicate debounce behaviour ootb. It should be the same for useQuery.

Explanation:

  • whenever a useQuery is re-triggered, react-query cancels the previous inflight request (based on queryKey).
  • the useQuery implementation below, waits half a second, and then checks whether the signal (i.e. this request attempt) has been aborted.
  • if not aborted, proceed!

One more thing...

  • pass the signal through to the axios request - that way the request (if somehow inflight) can be cancelled. Useful for big payloads!

e.g.

const sleep = (ms = 1000) => new Promise(resolve => setTimeout(resolve, ms))

const categoryQuery = useQuery({
    queryKey: ['category'],
    queryFn: async ({ signal }) => {
        await sleep(500)
        if (!signal?.aborted) {
            const response = await axios.get(`/api/products?category_id=${category_id}&${searchParams.toString()}&page=${page}`,
                { signal });
            return response.data;
        }
    },
    keepPreviousData: true
});

More information:

Related:

2 Comments

That's not efficient as the server still processes the request, better to not make it while debouncing
@nanobar agreed
3

You can use the React build-in hook useDeferredValue

Showing stale content while fresh content is loading Call useDeferredValue at the top level of your component to defer updating some part of your UI.

4 Comments

I'm not quite sure that useDefferedValue would help with react-query. See the note at the bottom of your link: "If the work you’re optimizing doesn’t happen during rendering, debouncing and throttling are still useful. For example, they can let you fire fewer network requests.". useDeferredValue is better suited to optimizing rendering, not reducing network requests.
React-query uses a built-in mechanism to cache data, so it will not re-fetch for the same API with the same params unless the data is stale. For example, if you use a search input that requests data on every 'onChange' event, the application will re-render after every key, with different API request params. That will cause a new API quest for every re-render even using react-query. Using useDeferredValue will reduce the number of re-renders of our application
You misinterpreted the docs. Yes react-query caches result, but as you can see, react-query is still called after each keystroke with useDeferredValue: see console in sandbox here. useDeferredValue still re-renders in the background. The only diff is that it might not commit intermediate changes if the client overloaded.
"Note that there is still a network request per each keystroke. What’s being deferred here is displaying results (until they’re ready), not the network requests themselves." - react.dev
3

In order to make lodash debounce to work with react-query, you’ll have to enable leading run of the function. Trailing run is required tanke sure you get the latest result.

debounce(fetchProducts, 500, {leading: true, trailing: true})

Source: https://lodash.com/docs/#debounce

2 Comments

the most underrated and quick, easy answer. Thanks!
@canbax tips fedora.
0

Following up on Cody Chang's response, as of 2024, TanStack query updated keepPreviousData to placeholderData. Below is an example I wrote:

import { useDebounce } from 'use-debounce';
import { keepPreviousData, useQuery } from '@tanstack/react-query';

const DEBOUNCE_MS = 300;

function QuickSwitcher() {
    const [open, setOpen] = useState(false);
    const [query, setQuery] = useState('');
    const [debouncedQuery] = useDebounce(query, DEBOUNCE_MS);
    const queryResult = useQuery({
        queryKey: ['quick-switcher', debouncedQuery],
        queryFn: async () => {
            if (!debouncedQuery) return [];
            const rows = await actions.pages.getPagesByFts({
                query: debouncedQuery,
            });
            return rows;
        },
        staleTime: DEBOUNCE_MS,
        placeholderData: keepPreviousData,
    });
    return (
        <CommandDialog open={open} onOpenChange={setOpen}>
            <Input
                placeholder="Type a command or search..."
                value={query}
                onChange={(e) => {
                    const newValue = e.target.value;
                    setQuery(newValue);
                }}
            />
        ...
        </CommandDialog>
    )
}

Comments

0

I have created a custom hook useDebounced.tsx for debouncing. I also wanted to have the value of the search in the URL:

TER project

// useDebounced.tsx
import { useState, useEffect } from "react"
import { useLocation, useNavigate } from "react-router-dom"

const useDebounced = (initialValue: string) => {
  const delay = 400
  const [inputValue, setInputValue] = useState(initialValue)
  const location = useLocation()
  const navigate = useNavigate()

  useEffect(() => {
    const handler = setTimeout(() => {
      const searchParams = new URLSearchParams(location.search)
      searchParams.set("search", inputValue)
      navigate(`${location.pathname}?${searchParams.toString()}`)
    }, delay)
    return () => clearTimeout(handler)
  }, [inputValue, location.pathname, navigate, location.search, delay])

  return [inputValue, setInputValue] as const
}

export default useDebounced

the Search component:

// Search.tsx

import useDebounced from "./useDebounced"
import { useLocation } from "react-router-dom"

const Search = () => {
  const location = useLocation()
  const query = new URLSearchParams(location.search)
  const search = query.get("search") || ""

  const [inputValue, setInputValue] = useDebounced(search)

  return (
    <input
      id="id-search"
      name="search"
      type="text"
      value={inputValue}
      className="mb-4"
      placeholder="Search"
      onChange={(e) => setInputValue(e.target.value)}
    />
  )
}

export default Search

and the list of users with the query.

// UsersPage.tsx
import { useLocation } from "react-router-dom"
import { trpc } from "../../utils/trpc"
import ErrorTemplate from "../../template/ErrorTemplate"
import Pagination from "./Pagination"
import ImgAvatar from "../../template/layout/ImgAvatar"
import Search from "../search/Search"

const UsersPage = () => {
  const location = useLocation()
  const query = new URLSearchParams(location.search)
  const page = query.get("page")
  const search = query.get("search") || undefined
  const pageNumber = page ? parseInt(page, 10) : 1
  const dataQuery = trpc.getUsers.useQuery({ page: pageNumber, search })
  if (dataQuery.isError) return <ErrorTemplate message={dataQuery.error.message} />

  return (
    <div className="flex flex-col h-full">
      <div className="flex-1 overflow-y-auto">
        <div className="p-4">
          <Search />
          <table>
            <thead>
              <tr>
                <th>Name</th>
                <th>Created At</th>
                <th>Last Login At</th>
                <th>Email</th>
                <th>Avatar</th>
              </tr>
            </thead>
            <tbody>
              {dataQuery.data?.users.map((user) => (
                <tr key={user.id}>
                  <td>{user.name}</td>
                  <td>{new Date(user.createdAt).toLocaleString()}</td>
                  <td>{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : ""}</td>
                  <td>{user.email}</td>
                  <td>
                    <ImgAvatar src={user.image} alt="Profile Image" className="w-10 h-10" />
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
          {dataQuery.isLoading && <div>Loading...</div>}
        </div>
      </div>
      <div className="border-t border-gray-200">
        <div className="sticky bottom-0 h-10 mr-6 mt-4">
          <div className="flex justify-end">
            {dataQuery.data && (
              <Pagination limit={dataQuery.data.limit} page={dataQuery.data.page} total={dataQuery.data.total} />
            )}
          </div>
        </div>
      </div>
    </div>
  )
}

export default UsersPage

Full code: https://github.com/alan345/TER

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.