Reputation: 313
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
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:
// 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
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
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
.
useQuery
is re-triggered, react-query cancels the previous inflight request (based on queryKey
).useQuery
implementation below, waits half a second, and then checks whether the signal
(i.e. this request attempt) has been aborted.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
});
Upvotes: 4
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
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
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