RTK-Query is often considered a data fetching layer, which is correct. While doing so, we turn actions like caching, refetching, how components subscribe to it, etc. into auxiliary capabilities. But we can also look at RTK-Query as cache-first. In this blog, we will discuss that first and see how it gives answers to our most frequently asked questions while working with RTK-Query, like “Why is my modal showing stale data?” and “Why are there redundant network requests?” etc.
RTK-Query isn’t a data-fetching tool but a client-side cache that happens to know how to fetch data. The fetching is the boring part. When we introduce RTK-Query in a project, we basically sign up for a cache with a subscription model, an invalidation graph, and a state machine underneath. Once we have this mental model, most of the challenges and problems start appearing as caching problems, and they have well-known answers.
Let me show what I mean.
The cache is the product
The most basic thing you can write inside a React component is:
const {data, isLoading} = useGetPostsQuery();
If I ask you what this code does, you will say it fetches ‘posts’ data, and YES, it does. But let’s try to define it from a caching point of view. We subscribe this component to a cache entry, which will give us the ‘posts’ data. RTK-Query looks at the cache entry, and if it exists, the data is served from the cache. No network request goes out. If the entry isn’t there, then the network request is fired, the result is saved in the cache, and every subscriber is notified about it.
The network requests are what happens on a cache miss (we will see the possible triggers that can invalidate the cache). That’s basically the whole mental model.
It also explains the two questions everybody eventually asks – “Why is it refetching the data?” and “why is my data stale?”. They are confusing if you are looking at it like a fetch library and obvious if you are looking at it like a cache.
The refetching one is usually eviction or any mutation/refetch trigger firing incorrectly. An entry only sticks around till the time something is subscribed to it, plus a short grace period (keepUnusedDataFor, 60s by default) or some mutation/refetch hasn’t invalidated it. When the last component using it unmounts and that timer has run out, the entry gets removed. So, the next time a component asks for it, that’s a cache miss and you see a fresh network request.
Staleness is the flip side of the exact same rule. As long as something is subscribed, the entry never expires on its own. It will keep returning the cached data until something tells it to refresh itself. Here, the “something” can be a few things like a mutation that invalidates a corresponding tag, a refetch trigger like refetchOnMountOrArgChange, refetchOnFocus, refetchOnReconnect, etc. and some other ways. If your data is stale, it’s almost always because none of the triggers fired and your cache never got the chance to update.
Working with the cache
We will now have a look at a few RTK-Query cache mechanics to solidify our understanding.
Tag Design (cache invalidation)
Tags are the mechanism how RTK-Query tracks the relationships between reads and writes. A query provides tags. A mutation invalidates them. Any cached query holding a tag that just got invalidated gets refetched. That’s how the invalidation works.
Let’s go through an example:
getPosts : builder.query<Post[], any>({
query: () => "posts",
providesTags : ['Post']
})
getPost : builder.query<Post, string>({
query : (id) => `posts/${id}`,
providesTags: ['Post']
})
updatePost: builder.mutation<Post, Partial<Post>>({
query : (patch) => ({
url: `posts/${patch.id}`, method : 'PATCH', body : patch
}),
invalidatesTags: ['Post']
})
With this code, if you try to update one post, then every query having ‘Post’ in its tags will be refetched: the list view, the counter in the sidebar, and all of it. You only edited a single post, and you will see networks’ requests that were not intended to be fired. This happened because the tag design is coarse.
The fix is to design tags with proper granularity
getPosts : builder.query<Post[], any>({
query: () => "posts",
providesTags : (result) => {
return result ? [
...result.map(({ id }) => ({type : 'Post' as const, id})),
{type: 'Post' as const, id : 'LIST'}
] : [{type: 'Post' as const, id : 'LIST'}]
}
})
Here the getPosts query maps over each post in the result and creates tags for each post. It also creates a sentinel tag that stands for the whole collection.
getPost : builder.query<Post, string>({
query : (id) => `posts/${id}`,
providesTags: (result, error, id) => [{type : 'Post', id}]
})
getPost query creates tags against the corresponding post id. If some mutation invalidates {type : ‘POST’, id: 50}, only this record refetches:
updatePost: builder.mutation<Post, Partial<Post> & Pick<Post, 'id'>>({
query : ({id, ...patch}) => ({
url: `posts/${id}`, method : 'PATCH', body : patch
}),
invalidatesTags: (result, error, {id}) => [{type: 'Post', id}]
})
updatePost mutation only invalidates queries with a tag for the given post ID. Suppose updatePost was called for post Id 50, then it will invalidate these queries – getPost(50) and getPosts(because it emitted a per-id tag for every post). Nothing happens to getPost(100), getPost(200) etc.
The conclusion is to always design the tags keeping the granularity of data in mind.
Optimistic Updates
Optimistic UI with proper rollback updates the screen instantly, then reconciles with the server and quietly undoes if the server rejects. Implementing this manually can be brittle and will need a lot of boilerplates, and subtle bugs might creep in.
Let’s see how RTK-Query comes in handy for this job, because it is already sitting on top of the cache it needs to manipulate.
updatePost: builder.mutation<Post, Partial<Post> & Pick<Post, 'id'>>({
query : ({id, ...patch}) => ({
url: `posts/${id}`, method : 'PATCH', body : patch
}),
async onQueryStarted({id, ...patch}, {dispatch, queryFulfilled}){
const undoPatch = dispatch(
fullkitApiSlice.util.updateQueryData('getPost', id, (draft) => {
Object.assign(draft, patch)
})
)
try{
await queryFulfilled
catch{
undoPatch.undo()
}
}
invalidatesTags: (result, error, {id}) => [{type: 'Post', id}]
})
Here, the onQueryStarted hook runs the moment it is dispatched, before the network call resolves. This is what makes the optimistic update possible; you get a chance to manipulate the cache while the request is in flight.
updateQueryData returns you an Immer draft of the cached value and hands back an undo function. You mutate the draft, wait for queryFulfilled and if it throws, you call .undo(). This whole setup gives the user an instant response, and in case of failure, the UI reverts silently. The invalidateTags can be removed in this case. If it is not, the invalidateTags will ultimately reconcile with the server. If the server sent something extra to update, that will be reflected.
selectFromResult : The performance lever
When a component calls the useGetPostsQuery hook, it subscribes to the cache entry and, by default, receives the full object. Since React re-renders whenever the data returned by a hook changes, the component watches the entire object and re-renders even if an unrelated portion of the data changes.
If there are thousands of Post rows and each row subscribes to the entire data set, then an update to a single row can trigger re-renders for all the other rows.
selectFromResult allows a component to subscribe to a specific slice of data instead of the whole thing and skip re-rendering when parts it doesn’t depend on change.
function PostRow({postId} : {postId : string}){
const {post} = useGetPostsQuery(undefined, {
selectFromResult : ({data}) => ({
post: data?.find((p) => p.id === postId)
})
})
return <div>{post?.title}</div>
}
The row only re-renders when its data changes. Use this capability anytime a component wants one field, a subset of data, a filteredCount, etc. out of a big cache entry
The key takeaway
RTK-Query doesn’t just fetch data. It is also a cache where your data lives. Almost every production headache around the data managed by RTK-Query—the redundant fetching, the stale data, etc.—is an underlying caching problem.