Tiramisu
Tiramisu

Reputation: 313

How to use debounce with useQuery in React Query?

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!

Upvotes: 31

Views: 49664

Answers (6)

Alan
Alan

Reputation: 10145

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

Upvotes: 0

Braden Wong
Braden Wong

Reputation: 703

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>
    )
}

Upvotes: 0

Nick Grealy
Nick Grealy

Reputation: 25872

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:

Upvotes: 4

Dima Vishnyakov
Dima Vishnyakov

Reputation: 1542

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

Upvotes: 3

Adel Benyahia
Adel Benyahia

Reputation: 429

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.

Upvotes: 3

Cody Chang
Cody Chang

Reputation: 973

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

Upvotes: 54

Related Questions