1. Introduction: When does Server State become stale?
When running the frontend, there are two types of reports that you will repeatedly hear from users. One is, “I just registered, but it’s not showing on the list,” and the other is the opposite, “I was idle, but the screen suddenly blinked and reloaded.” These two symptoms may seem unrelated at first glance, but they actually stem from the same root. It is the lack of a caching policy for the server's data, specifically: “For how long and on what basis will the client believe that it is still valid?”
Many teams are solving this problem with React Query (currently TanStack Query), and my project is no exception. However, when you actually look at the code, it is very common that the consensus on the most important question, “How long should we keep the cache?” is spread out over individual hooks rather than being centralized.
The value of React Query starts from distinguishing between “Server State” and “Client State.” Values such as whether a modal is open are client states owned by the screen, but data with the source on the server, like lists, details, and user information, are merely copies we borrow for a time. This copy can change on the server even while I am viewing it. This means,useState + useEffect if handled manually, will require managing loading and error flags directly, and requesting the same data from multiple screens will lead to duplicate requests. React Query handles this repetition by caching responses with a label called queryKey. Ultimately, “how we manage the cache” is the essence of using this library well.
2. Core Concepts: fresh, stale, and cache lifetime
2.1 staleTime — the ‘expiration date’ of the data
All data in React Query is either fresh or stale. Data that has just been fetched is fresh, and after the staleTime passes, it becomes stale. It can be easily likened to food expiration dates. The key point is that even when it becomes stale, the data is not discarded. Expired food can still be served at the table, but with a note that says, “I’ll replace it with fresh when I have the chance to shop next.” And there’s a catch. The default value of staleTime is 0, meaning that data becomes stale immediately after it is retrieved.
2.2 gcTime — the ‘storage period’ of the cache
When a component using a query disappears from the screen, that query becomes inactive. gcTime is the time to wait before clearing this inactive cache from memory, and the default is 5 minutes. This corresponds to the storage period. If you return to the same screen within this time, the cache remains alive and can be shown immediately; if it passes, it becomes a candidate for garbage collection, requiring retrieval from the beginning next time.
When does refetch happen?
stale data is refetched at four moments: when the component mounts, when the browser window regains focus, when a previously disconnected network reconnects, and when we explicitly call for invalidation. However, all these triggers only work when the data is stale. If it's fresh, refetch will not occur no matter how often the window is focused. Thus, most of the question of 'how often do we refetch' depends on how we set staleTime.
|
Separator |
staleTime |
gcTime |
|---|---|---|
|
Questions to ask |
How fresh is it |
How long is it retained |
|
Default |
0 (immediate stale) |
5 minutes |
|
Effect range |
Refetch timing |
Memory usage / revisit speed |
|
Even after the period ends |
Data is kept on the screen |
The cache itself is deleted |
3. Common Cache Anti-patterns
3.1 Sprinkling staleTime in hooks without global criteria
If you don't set a global default, staleTime will become 0. So every time you re-enter the screen, every time a component is remounted, the same request is sent again. This late realization leads developers to start manually inserting staleTime for each screen in one or two places, where some choose 1 minute, some 5 minutes, and most remain unset. Without a basis for the values, newcomers lose the criteria to judge 'how often should this data be updated'.
3.2 Mismatch between query key and invalidate key
This is the most common yet elusive real bug. For example, let's assume the list of posts is cached with one key as shown below, and after registration, an unrelated key is invalidated.
// 목록은 이 키로 캐싱했는데
useQuery({ queryKey: ['posts'], ... })
// 글 등록 후엔 다른 키를 무효화한다면
queryClient.invalidateQueries({ queryKey: ['postList'] }); // 매칭 안 됨 → 목록 그대로
The two keys are different, so they do not match, and the list cache remains. The registration is successful, but the screen is not updated. The compiler cannot warn about this since it cannot tell if they point to the same data. It seems trivial, but writing keys as strings is a repeated mistake in any project as long as you do it manually. In fact, I've seen cases in the same file where it invalidated the correct key when deleting, but used the wrong key when registering, leading to a delicate bug where 'deletion works, but registration does not appear on the screen'.
3.3 Manual fetching outside of cache coexists
Some screens fetch the same data using React Query, while others still use useState and useEffect. This can lead to one side having a cache while the data can be mismatched, resulting in the loss of benefits like deduplication and automatic refresh on that screen. This is especially common in codebases that are not fully migrated.
4. Effective Cache Management Strategy
4.1 Agree on global defaults (Default Options) first
The first thing to do is to specify the baseline for the cache in the QueryClient's defaultOptionswith a reasonable value for staleTime rather than 0, refetchOnWindowFocusrecommends turning it on only for screens that require real-time performance after turning it off globally. This one-time setting organizes version 3.1 and eliminates the need to repeatedly write the same option in individual hooks.
new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 30, // 기준선: 30초 동안은 신선하다고 본다
refetchOnWindowFocus: false, // 기본은 끄고, 필요한 화면만 켠다
retry: 1, // 실패 시 과한 재시도 방지
},
},
});
What matters more than the number itself is the fact that 'the baseline exists in one place in the code.' Now, individual hooks only need to overwrite the option when they have to deviate from the baseline, and that overwritten spot becomes a signal that 'this data is special.'
Classifying staleTime by the nature of the data
Do not decide 'how many minutes for staleTime' for each screen. The benchmark is not the screen but how frequently the data changes. By dividing the data into three categories, the concerns when creating new queries disappear. For master data that rarely changes, it's efficient to set a long staleTime (sometimes even Infinity) and make a promise to 'invalidate directly when the value actually changes.' Then, it will typically not use the network at all and will only refresh once when it actually changes. gcTime is usually sufficient at a default of 5 minutes, but if there’s a flow that stays on one screen for a long time, like kiosks or embedded interfaces, it is adjusted to avoid 'reloading when you come back.' In memory-constrained environments, it can be reduced.
|
Classification |
Example |
Recommended staleTime |
|---|---|---|
|
Master (almost immutable) |
Category, code table, organization/department structure |
5-10 minutes or more + invalidate directly on change |
|
Quasi-static (occasionally changes) |
My profile, permissions/settings |
1-3 minutes |
|
Transactional (frequently changes) |
Order/Post List, Notifications, Inventory |
0-30 seconds |
4.3 The query key is unified as 'factory'
The method to structurally eliminate the key mismatch bug in 3.2 is simple. Instead of writing the key manually, it goes through a function (factory) that creates the key. If designed hierarchically, invalidation becomes significantly stronger.
export const postKeys = {
all: ['posts'] as const,
lists: () => [...postKeys.all, 'list'] as const,
list: (filters) => [...postKeys.lists(), filters] as const,
detail: (id) => [...postKeys.all, 'detail', id] as const,
};
It is useful to understand one mechanism here.invalidateQueriesoperates on a 'partial match (prefix match)' of the key. In other words, invalidateQueries({ queryKey: postKeys.all })a single line invalidates all cached lists and details like ['posts','list', ...] at once. Conversely, if you only want to refresh a specific post, you can simply invalidate postKeys.detail(id)only. Since both queries and invalidations pull keys from the same factory, the chances of string mismatches are fundamentally eliminated. By defining the key in one place, you also benefit from autocompletion and type checking.
4.4 Invalidation is not the only answer: Direct updates and optimistic updates
Invalidation is an instruction that says, 'This cache is stale, please fetch it again.' It's safe but triggers an additional network request. However, if the response from the change API already contains the latest data, there is no need to fetch it again. setQueryDataIt's faster and more economical to directly swap the cache. It's saving a whole request at once.
Going a step further, we use optimistic updates for actions where immediate response is crucial, like a like toggle or checkbox.onMutateAt this stage, we change the cache in advance without waiting for the server response, refreshing the screen immediately, and if the request fails, we roll back to the previous value. The user hardly feels any delay. In summary, you go in the order of ‘invalidate → setQueryData → optimistic’, and the more immediacy is important, the more you should opt for the right side.
4.5 Smooth transitions: keepPreviousData and placeholderData
In cases like pagination or search where parameters only change on the same screen, the screen flashes empty with a spinner every time the key changes. At this time, placeholderData: keepPreviousDatashows the previous page's data until the new data arrives. The table transitions naturally to the next page without flashing out of existence. It's the cheapest way to enhance perceived quality in list/search UIs, and if you're already using it well, I recommend expanding it to other lists.
4.6 Pre-hiding the wait: prefetch and initialData
If it's predictable what the user will see next, we can pre-fetch that data. The moment the user hovers over an item in the list, prefetchQuerycan fetch the details in advance, so when they actually click, it's already in the cache and opens immediately. Conversely, if there's data already in hand (if part of the details is included in the list response or fetched through SSR), initialDatacan fill the initial value of the cache so we can skip the first loading. Both are techniques that “move the waiting time out of the user's sight.”
4.7 Viewing Cache: Devtools
Finally, I strongly recommend attaching it only to the development build,@tanstack/react-query-devtoolswhich visually shows what state each query is in: fresh, stale, or inactive, whether invalidation actually took effect, and how many keys are floating around. Key mismatches like in 3.2 become apparent after just opening Devtools once. It turns the cache issues you were debugging by guesswork into something you can visually confirm.
5. Conclusion
Using React Query well is not about utilizing flashy features, but rather about codifying promises regarding the cache in one place. First, establish global defaults to create a baseline, second, categorize staleTime according to the nature of data, and third, gather query keys in a factory. With these three alone, most symptoms like “the list doesn’t change even though I registered” and “it flickers every time I move the screen” will disappear.
Adding another layer on top is efficiency. Instead of handling every change as an invalidation and fetching anew each time, update the cache directly with the response (setQueryData), handle where immediacy is needed with optimistic updates, and prefetch the next predictable screen. Once you start viewing the cache issue as “when and how to fill it” instead of “when to empty it,” you can create screens that operate much faster and quietly even on the same server and network. Ultimately, a good cache strategy is about making the user feel no waiting.
References
-
TanStack Query – Important Defaults — https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults
-
TanStack Query – Caching Mechanism — https://tanstack.com/query/latest/docs/framework/react/guides/caching
-
TanStack Query – Query Invalidation — https://tanstack.com/query/latest/docs/framework/react/guides/query-invalidation
-
TanStack Query – Optimistic Updates — https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates
-
TanStack Query – Prefetching — https://tanstack.com/query/latest/docs/framework/react/guides/prefetching
-
TkDodo – Effective React Query Keys — https://tkdodo.eu/blog/effective-react-query-keys
-
TkDodo – Practical React Query — https://tkdodo.eu/blog/practical-react-query
toffeeman