3 Prefetching and UX Optimization
Prefetching means anticipating user actions and requesting data in advance.
The benefit of prefetching is reduced user waiting time and improved UX; the downside is potential wrong predictions wasting network resources, but compared to the advantages, it can be negligible.
Query-Based Prefetching Implementation
Suppose we have a book list showing book titles; clicking a title navigates to the detail page to view the book introduction and review list.
Usually we implement it like this:
- Click a list item, enter the detail page.
- The detail page requests data and displays it.
- The user clicks the back button to return to the list page; to ensure the list data is up-to-date, data is re-fetched.
- The user clicks another list item, enters the detail page again, and requests data again.
The downside of this implementation is poor user experience. I can think of at least two optimization points:
- If real-time data requirements are not extremely high, the requested detail and list pages can be cached without re-fetching every time.
- When the user hovers over a list item, data can be prefetched in advance. By the time the user clicks the item, the data has already been fetched and placed in the cache, reducing waiting time.
Caching was discussed earlier: query provides out-of-the-box cache management.
For prefetching, query provides the prefetchQuery method.
The example code below is noteworthy: the onMouseEnter event triggers prefetching, and useQueryClient is used to obtain the queryClient.prefetchQuery method:
import { useQueryClient } from "@tanstack/react-query";
const queryClient = useQueryClient();
...some tsx...
<li onMouseEnter={() => {
queryClient.prefetchQuery({
queryKey: ["book", id],
queryFn: () => fetchBookDetail(id),
staleTime: 60 * 1000, // avoid overly frequent network requests
});
}}
>
Provide the
staleTimeparameter toprefetchQuery; it specifies the valid duration of cached data, effectively preventing overly frequent network requests.
We prefetched the detail page data using prefetchQuery. When the user clicks a list item, the data has already been fetched and placed in the cache; then we simply retrieve the data from the cache.
const useBookDetail = (id: number) => {
const queryClient = useQueryClient()
return useQuery({
queryKey: ['book', id],
queryFn: () => fetchBookDetail(id),
initialData: () => {
const cache = queryClient.getQueryData<Book>(['bookDetail', id])
return cache
},
})
}
Here we retrieve data from the cache using the initialData parameter.
Two things to note:
- The data type of
idmust be consistent. When adding cached data toqueryClientviaprefetchQuery, thequeryKeyused for storage must be exactly the same as for retrieval. Never switchidbetweennumberandstring; otherwise the retrieved data will beundefined. - When not using
initialData,useQueryinfers thedatatype fromqueryFn. When usinginitialData, thedatatype becomesunknownbecause the type ofinitialDatacannot be automatically inferred, so we need to manually specify it. Here we use generics to specify it asBook.
Placeholder Data
Prefetching is not a silver bullet; it cannot completely eliminate user waiting time, only reduce it.
If the user is extremely fast and enters the detail page before prefetching completes, there will still be waiting time.
If the network request hasn't responded, should the detail page just display a big loading indicator?
Of course not. When on the list page, we already have some basic data, such as the book title and author. So the detail page can first display the book title and author. Query provides the placeholderData parameter to set placeholder data.
queryClient provides the getQueryData method to retrieve cached data.
Note: the queryKey for getQueryData is ['book'], without id. That's because we want to retrieve the cached data of the list page, whose queryKey is ['book'].
const useBookDetail = (id: number) => {
const queryClient = useQueryClient()
return useQuery({
queryKey: ['book', id],
queryFn: () => fetchBookDetail(id),
placeholderData: () => {
const data = queryClient.getQueryData(['book']).find((item) => item.id === id) ?? undefined
return data
},
})
}
Complete Example
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { fetchBooks, fetchBookDetail, Book } from '../api/book'
type PropsType = {
id: number
setBookId: (id: number | undefined) => void
}
const useBookDetail = (id: number) => {
const queryClient = useQueryClient()
return useQuery({
queryKey: ['book', id],
queryFn: () => fetchBookDetail(id),
placeholderData: () => {
const data =
(queryClient.getQueryData(['book']) as { id: number }[]).find((item) => item.id === id) ??
undefined
return data as Book
},
})
}
const BookDetail = ({ id, setBookId }: PropsType) => {
// isPlaceholderData is a more accurate indicator
const { data, isPlaceholderData } = useBookDetail(id)
return (
<div>
<button onClick={() => setBookId(undefined)}>back</button>
{isPlaceholderData ? (
<>
<h1>{data?.title}</h1>
<p>loading...</p>
</>
) : (
<>
<h1>{data?.title}</h1>
<p>author: {data?.author}</p>
<p>description: {data?.description}</p>
</>
)}
</div>
)
}
const useBookList = () => {
return useQuery({ queryKey: ['book'], queryFn: fetchBooks })
}
const BookList = ({ setBookId }: { setBookId: (id: number) => void }) => {
const { data, isLoading } = useBookList()
const queryClient = useQueryClient()
const handlePrefetch = (id: number) => {
queryClient.prefetchQuery({
queryKey: ['book', id],
queryFn: () => fetchBookDetail(id),
staleTime: 60 * 1000, // avoid overly frequent network requests
})
}
if (isLoading) {
return <p>loading</p>
}
return data?.map((item) => (
<div onClick={() => setBookId(item.id)} onMouseEnter={() => handlePrefetch(item.id)}>
{item.title}
</div>
))
}
const BookApp = () => {
const [bookId, setBookId] = useState<number | undefined>(undefined)
return (
<>
{bookId ? (
<BookDetail id={bookId} setBookId={setBookId} />
) : (
<BookList setBookId={setBookId} />
)}
</>
)
}
export default BookApp