This episode covers data security: handling 401 and 403 responses with refresh tokens, avoiding storing sensitive data in the cache, sanitizing errors, as well as query key best practices and separating server state from client state.

A cache that stores server data is a double-edged sword: on one side it speeds up the application, on the other it stores data that could leak if not managed carefully. Thinking about security after the application is large isn't an option — the fundamentals must be embedded from the start.
Episode 15 covers security from TanStack Query's angle: how to handle 401 and 403, rules for sensitive data in the cache, error sanitization, and code structure best practices that keep the project healthy.
When an access token expires, the server returns 401. A common pattern: refresh the token automatically, then retry the request. TanStack Query doesn't provide an interceptor like axios, so handle it in queryFn or in the API layer:
async function authedFetch(url) {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${getAccessToken()}` },
})
if (res.status === 401) {
const refreshed = await tryRefreshToken()
if (refreshed) {
return authedFetch(url)
}
redirectToLogin()
throw new Error("Sesi berakhir")
}
if (res.status === 403) {
throw new Error("Akses ditolak")
}
return res.json()
}When res.status === 401, the function tries to refresh the token and then retries the request once. If the refresh fails, the user is redirected to login. authedFetch consolidates the authentication logic so all queryFns use one safe path.
Instead of rewriting the 401 logic in every queryFn, make authedFetch the standard wrapper for all API calls:
const { data } = useQuery({
queryKey: ["todos"],
queryFn: () => authedFetch("/todos"),
})authedFetch("/todos") gives every query consistent 401 handling. A common mistake: handling 401 in several places with different behaviors, so an expired session shows random errors on each page.
403 means the user is valid but not authorized. Handle it differently from 401 — refreshing the token is pointless. Show an "access denied" message and don't cache 403 responses.
The TanStack Query cache lives in memory, and if persisted (episode 12) it will be stored in localStorage, which can be read by scripts on the same origin. Simple rules:
gcTime for sensitive data.The error object thrown by queryFn can contain internal details that aren't fit to display. Never show the server's error.message directly in the UI:
function getUserFacingMessage(error) {
if (error instanceof AuthError) return "Sesi berakhir, silakan masuk lagi"
if (error instanceof ApiError) return "Terjadi kesalahan, coba lagi nanti"
return "Kesalahan tak terduga"
}getUserFacingMessage maps error types to safe messages for the user. Don't display stack traces or raw response details in the UI — keep technical details in logs accessible only to developers.
From episode 5, query key consistency is the key. Establish a convention early on:
["todos", "list", { filter }]
["todos", "detail", id]
["users", id, "posts"]The ["todos", "list", { filter }] convention establishes a domain → resource → modifier pattern. A consistent convention makes invalidation easy to reason about and prevents duplicate keys that silently read the wrong cache.
This is the most important principle of the entire series: TanStack Query for server state, not client state. UI state like an open modal, active tab, theme, or draft form contents is client state — store it in useState, Zustand, or Redux. Mixing the two makes the cache hold data that isn't its responsibility.
TanStack Query → data from the server (todos, users, profile)
Zustand/Redux → UI state (modal, theme, cart, temporary filters)The division TanStack Query → data from the server and Zustand/Redux → UI state avoids duplication and confusion. Episode 16 will discuss this hybrid pattern more deeply.
Tip
Start the project by writing down the query key convention and sensitive data rules in the team documentation. These small decisions save many security bugs and inconsistencies in the future.
Episode 15 equipped you with security and structure practices: consistent 401 and 403 handling, rules for sensitive data in the cache, error sanitization for the user, and best practices for query keys and separating server state from client state.
Key takeaways:
In the next episode, episode 16, we will discuss integration with state management — the hybrid pattern of TanStack Query for server state with Zustand or Redux for UI state, and treating the cache as a single source of truth with getQueryData.