This episode covers ways to reduce the number of fetches: global versus per-query staleTime, keepPreviousData, and cache normalization. You also learn Suspense mode with useSuspenseQuery and React Suspense for granular loading.

TanStack Query is already automatically efficient, but "automatic" doesn't mean it can't be made better. An application with dozens of queries can produce hundreds of unnecessary requests — because staleTime is too small, refetching is too aggressive, or the query key design is inefficient.
Episode 13 covers performance tuning: setting staleTime strategically, using keepPreviousData, and normalizing the cache structure. Then we move into Suspense mode, the modern way to handle loading integrated with React Suspense.
The first decision: where to set staleTime? The global configuration in QueryClient provides a baseline, but different data needs different freshness.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
},
},
})
// per-query: data hampir statis
useQuery({
queryKey: ["config"],
queryFn: fetchConfig,
staleTime: 60 * 60 * 1000,
})staleTime: 30 * 1000 globally becomes the baseline for all queries, and the config query overrides it to 1 hour because its data almost never changes. A larger staleTime means fewer requests. Start with a moderate global value, then adjust per query for the most frequently mounted ones.
keepPreviousData (from episode 9) isn't just for pagination — it's useful for UIs that move from one item to another, like details that change quickly. The old data stays visible, the new query runs in the background, and the user never sees a blank screen.
useQuery({
queryKey: ["todos", id],
queryFn: () => fetchTodo(id),
placeholderData: keepPreviousData,
})placeholderData: keepPreviousData keeps the content visible when id changes. keepPreviousData makes navigation between items feel smooth without disruptive spinners.
One source of data duplication: the cache stores the same todo object many times — once in the list, once in the detail, once in every other query. Normalization stores the entity in one place with setQueryData, and other queries just reference its id:
queryClient.setQueryData(["entities", "todos"], (old) => ({
...(old ?? {}),
[todo.id]: todo,
}))["entities", "todos"] becomes the single store for all todos, keyed by id. queryClient.setQueryData(["entities", "todos"], ...) makes centralized invalidation easier and reduces memory duplication. Full normalization is usually brought in by state libraries like Redux Toolkit — episode 16 will discuss the integration.
React Suspense lets components "wait" without writing manual loading logic. When a query is suspended, React shows a fallback defined at the boundary:
import { Suspense } from "react"
<Suspense fallback={<p>Memuat todos...</p>}>
<Todos />
</Suspense>When Todos waits for a query, React shows the fallback until the data is ready. Suspense replaces manual isLoading checks — components can write code as if the data is always available.
To use Suspense, replace useQuery with useSuspenseQuery:
import { useSuspenseQuery } from "@tanstack/react-query"
function Todos() {
const { data } = useSuspenseQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
})
return (
<ul>
{data.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)
}useSuspenseQuery throws a Promise when data doesn't exist yet, and React Suspense catches it to show the fallback. useSuspenseQuery returns data that is always available when the code runs — there's no isLoading inside the component, and errors are handled by an Error Boundary.
The power of Suspense is visible when fallbacks are made granular per page section, not one big spinner:
<Suspense fallback={<p>Memuat user...</p>}>
<UserHeader />
</Suspense>
<Suspense fallback={<p>Memuat todos...</p>}>
<Todos />
</Suspense>Two separate boundaries mean the header and the list load independently. Suspense per section gives you full control over the loading experience — fast parts appear first, instead of waiting for the entire page.
Tip
Suspense mode removes manual isLoading checks, but don't forget to handle errors with an Error Boundary. TanStack Query throws errors from useSuspenseQuery to the nearest boundary, so make sure a boundary exists around the area using this hook.
Episode 13 improved the application's efficiency from two directions: reducing requests with staleTime tuning, keepPreviousData, and cache normalization, then simplifying loading with Suspense mode and useSuspenseQuery.
Key takeaways:
staleTime as a baseline; per-query for specific data.keepPreviousData eliminates flicker when switching data.useSuspenseQuery returns data without an isLoading status.In the next episode, episode 14, we will discuss SSR and framework integration — hydrating the cache on the server with dehydrate and HydrationBoundary for Next.js App Router, prefetching on the server, and integration with Remix loaders and framework-agnostic patterns.