| | |

Redux Toolkit 15 ⚛️ RTK Query Caching — Managing automated cache tags and invalidation

⏱️ Estimated read: 13 min | |

RTK Query caching is built on two separate mechanisms that work together. The first is the cache lifetime: every endpoint’s data is stored in a normalized cache, and the keepUnusedDataFor option controls how long an unused entry survives before it is removed. The second is the tag system: the providesTags and invalidatesTags options connect the queries and the mutations so that a successful write automatically refetches the reads that depend on it. The previous chapter covered the endpoint definitions; this chapter covers the cache behavior that those definitions produce: the default lifetime, the subscription model, the tag granularity, and the options that tune the invalidation.

The RTK Query documentation describes the cache as a normalized store keyed by the endpoint name and the serialized argument. The component’s subscription is what keeps the entry alive. When the last component that uses a query unmounts, the entry becomes unused, and the keepUnusedDataFor timer starts. When the timer expires, the entry is removed . The tag system is separate: the query declares which tags it provides, the mutation declares which tags it invalidates, and RTK Query refetches the queries that provide the invalidated tags . The two mechanisms can be configured independently, and the configuration determines whether a component sees fresh data, stale data, or no data at all.

Key point: The keepUnusedDataFor option defaults to 60 seconds. It controls how long an unused cache entry survives after the last subscriber unmounts. The refetchOnMountOrArgChange option controls whether the query refetches when the component mounts or the argument changes, even if the cached data is still fresh. The providesTags and invalidatesTags options control the tag-based invalidation, and the tag can be a string, a { type, id } object, or a function that returns the tags. The tag granularity determines whether an invalidation refetches one item or the whole list.


Why RTK Query caching requires two mechanisms

The lifetime problem.

A query’s data is not kept forever. When the last component that uses the query unmounts, the cache entry becomes unused, and the keepUnusedDataFor timer starts. If the user navigates back to the page before the timer expires, the cached data is returned immediately, and no refetch occurs . If the timer expires, the entry is removed, and the next mount fetches the data again. The default of 60 seconds is a compromise: long enough to make navigation feel instant, short enough to avoid unbounded memory growth.

The invalidation problem.

The lifetime mechanism does not know when the server’s data has changed. A mutation that adds a post does not affect the lifetime timer of the getPosts query, and the query continues to serve the stale list until the timer expires or the component refetches. The tag system solves this: the mutation declares the tags it invalidates, the query declares the tags it provides, and RTK Query refetches the query when the tags match . The two mechanisms are complementary: the lifetime controls memory, and the tags control freshness.

The granularity problem.

A tag can be a string, which applies to the whole endpoint, or an object with a type and an id, which applies to a specific item. The providesTags: ['Post'] form tags the whole list, and the invalidatesTags: ['Post'] form invalidates the whole list. The providesTags: (result) => result.map(({ id }) => ({ type: 'Post', id })) form tags each item, and the invalidatesTags: (result, error, id) => [{ type: 'Post', id }] form invalidates one item . The granularity determines whether the invalidation refetches one item or the entire list, and the choice depends on what the mutation affects.


a. The cache lifetime and the subscription model

The keepUnusedDataFor option sets the lifetime of the cache entry. It can be set at the API level or on a specific endpoint.

export const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api/' }),
  tagTypes: ['Post'],
  // Default lifetime for all endpoints
  keepUnusedDataFor: 60,
  endpoints: (build) => ({
    getPosts: build.query<Post[], void>({
      query: () => 'posts',
      // Override the lifetime for this endpoint
      keepUnusedDataFor: 300,
      providesTags: ['Post'],
    }),
  }),
})

The keepUnusedDataFor: 60 sets the default to 60 seconds, and the keepUnusedDataFor: 300 on the getPosts endpoint sets the lifetime to 5 minutes for that specific query . The value is in seconds, and the timer starts when the last subscriber unmounts. The RTK Query documentation notes that the data is not removed immediately when the component unmounts; it is removed after the timer expires, which allows the user to navigate back and see the cached data.

The refetchOnMountOrArgChange option controls whether the query refetches when the component mounts or the argument changes. It can be set to a boolean or a number of seconds.

// Refetch if the data is older than 30 seconds
getPosts: build.query<Post[], void>({
  query: () => 'posts',
  refetchOnMountOrArgChange: 30,
  providesTags: ['Post'],
}),

The refetchOnMountOrArgChange: 30 option tells the query to refetch if the cached data is older than 30 seconds when the component mounts or the argument changes . The boolean true refetches on every mount, and the default false uses the cached data regardless of its age. The option is the mechanism that makes the cache lifetime independent of the staleness: the lifetime controls how long the data survives, and the refetchOnMountOrArgChange controls whether the data is used when the component mounts.


b. The tag granularity and the invalidation

The tag system connects the queries and the mutations. The query declares the tags it provides, and the mutation declares the tags it invalidates. The tag can be a string or an object with a type and an id.

export const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api/' }),
  tagTypes: ['Post'],
  endpoints: (build) => ({
    // Provides a tag for each post and a tag for the list
    getPosts: build.query<Post[], void>({
      query: () => 'posts',
      providesTags: (result) =>
        result
          ? [...result.map(({ id }) => ({ type: 'Post' as const, id })), { type: 'Post', id: 'LIST' }]
          : [{ type: 'Post', id: 'LIST' }],
    }),

    // Provides a tag for the single post
    getPost: build.query<Post, number>({
      query: (id) => `posts/${id}`,
      providesTags: (result, error, id) => [{ type: 'Post', id }],
    }),

    // Invalidates the list tag
    addPost: build.mutation<Post, NewPost>({
      query: (body) => ({ url: 'posts', method: 'POST', body }),
      invalidatesTags: [{ type: 'Post', id: 'LIST' }],
    }),

    // Invalidates the tag for the specific post
    updatePost: build.mutation<Post, { id: number; changes: Partial<Post> }>({
      query: ({ id, changes }) => ({ url: `posts/${id}`, method: 'PATCH', body: changes }),
      invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
    }),
  }),
})

The getPosts query provides a tag for each post and a tag for the list. The getPost query provides a tag for the specific post. The addPost mutation invalidates the list tag, which refetches the getPosts query. The updatePost mutation invalidates the tag for the specific post, which refetches the getPost query for that post and the getPosts query if it provides the post tag . The granularity is the key: the addPost mutation affects the list, and the updatePost mutation affects the specific post.

The invalidatesTags function form receives the result, the error, and the argument. The updatePost mutation uses the function form to extract the id from the argument, and it returns the tag for that post . The addPost mutation uses the static form because the new post’s ID is not known until the server responds, and the list tag is the correct target.


c. The refetch and the manual invalidation

RTK Query refetches the invalidated queries automatically, but the component can also trigger a manual refetch or invalidation.

// Manual refetch from the query hook
const { refetch } = useGetPostsQuery()
<button onClick={() => refetch()}>Refresh</button>

// Manual invalidation from the API object
import { api } from '../services/api'

api.util.invalidateTags(['Post'])

The refetch function on the query hook triggers a new fetch for that specific query . The api.util.invalidateTags(['Post']) call invalidates every query that provides the Post tag, which is the programmatic equivalent of the mutation’s invalidatesTags option. The api.util.updateQueryData method is the mechanism for updating the cache directly without a refetch.

// Optimistically update the cache
api.util.updateQueryData('getPosts', undefined, (draft) => {
  draft.push({ id: Date.now(), title: 'New', body: '...' })
})

The updateQueryData method receives the endpoint name, the argument, and an updater function that mutates the draft. The Immer library handles the immutability, and the cache is updated without a refetch . The method is the RTK Query equivalent of the optimistic-update pattern, and it is used when the mutation’s response contains the new data and the refetch would be redundant.


Complete Example Session

// ============================================
// PART 1: THE API WITH THE CACHE OPTIONS
// ============================================
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

interface Post {
  id: number
  title: string
  body: string
}

export const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api/' }),
  tagTypes: ['Post'],
  keepUnusedDataFor: 60,
  endpoints: (build) => ({
// ============================================
// PART 2: THE QUERY WITH GRANULAR TAGS
// ============================================
    getPosts: build.query<Post[], void>({
      query: () => 'posts',
      keepUnusedDataFor: 300,
      providesTags: (result) =>
        result
          ? [...result.map(({ id }) => ({ type: 'Post' as const, id })), { type: 'Post', id: 'LIST' }]
          : [{ type: 'Post', id: 'LIST' }],
    }),

// ============================================
// PART 3: THE MUTATION THAT INVALIDATES THE LIST
// ============================================
    addPost: build.mutation<Post, Pick<Post, 'title' | 'body'>>({
      query: (body) => ({ url: 'posts', method: 'POST', body }),
      invalidatesTags: [{ type: 'Post', id: 'LIST' }],
    }),

// ============================================
// PART 4: THE MUTATION THAT INVALIDATES A SINGLE ITEM
// ============================================
    updatePost: build.mutation<Post, { id: number; changes: Partial<Post> }>({
      query: ({ id, changes }) => ({ url: `posts/${id}`, method: 'PATCH', body: changes }),
      invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
    }),
  }),
})

export const { useGetPostsQuery, useAddPostMutation, useUpdatePostMutation } = api

// ============================================
// PART 5: THE COMPONENT
// ============================================
function Posts() {
  const { data: posts = [], isLoading, refetch } = useGetPostsQuery()
  const [addPost] = useAddPostMutation()
  const [updatePost] = useUpdatePostMutation()

  if (isLoading) return <p>Loading...</p>

  return (
    <div>
      <button onClick={() => addPost({ title: 'New', body: '...' })}>Add</button>
      <button onClick={() => refetch()}>Refresh</button>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            {post.title}
            <button onClick={() => updatePost({ id: post.id, changes: { title: 'Updated' } })}>
              Update
            </button>
          </li>
        ))}
      </ul>
    </div>
  )
}

The five parts demonstrate the cache configuration: the API with the lifetime options, the query with the granular tags, the mutation that invalidates the list, the mutation that invalidates a single item, and the component. The critical distinction is between Parts 3 and 4: the addPost mutation invalidates the list tag, and the updatePost mutation invalidates the tag for the specific post.


Quick Reference

Cache Options

OptionDefaultPurpose
keepUnusedDataFor60Seconds an unused entry survives
refetchOnMountOrArgChangefalseRefetch on mount or arg change
refetchOnFocusfalseRefetch when the window regains focus
refetchOnReconnectfalseRefetch when the network reconnects
pollingInterval0Refetch on an interval

Tag Forms

FormExampleScope
String'Post'Whole endpoint
Object{ type: 'Post', id: 1 }One item
Object list{ type: 'Post', id: 'LIST' }A named list
Function(result, error, arg) => [...]Dynamic

Utility Methods

MethodPurpose
api.util.invalidateTagsInvalidate tags programmatically
api.util.updateQueryDataUpdate the cache directly
api.util.resetApiStateReset the entire cache
refetch() from the hookRefetch a single query

Best Practices

✅ Do This:

// Set the lifetime for the API or the endpoint
keepUnusedDataFor: 60                                          # ✅

// Use the list tag for the collection
providesTags: [{ type: 'Post', id: 'LIST' }]                   # ✅

// Invalidate the list tag on create
invalidatesTags: [{ type: 'Post', id: 'LIST' }]                # ✅

// Invalidate the item tag on update
invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }] # ✅

// Use refetch for the manual refresh
const { refetch } = useGetPostsQuery()                         # ✅

// Use updateQueryData for the optimistic update
api.util.updateQueryData('getPosts', undefined, (draft) => { ... }) # ✅

❌ Don’t Do This:

// Invalidate the whole type for a single-item update
invalidatesTags: ['Post']    // Refetches every query            # ❌

// Forget the providesTags
// The mutation cannot invalidate the query                      # ❌

// Use refetchOnMountOrArgChange: true everywhere
// The data refetches on every mount                             # ❌

// Set keepUnusedDataFor to 0
// The cache entry is removed immediately                        # ❌

// Use the wrong tag type
invalidatesTags: ['User']    // No effect                       # ❌

Common Pitfalls

PitfallWhy It HappensFix
Query not refetched after mutationTags do not matchMatch providesTags and invalidatesTags
Whole list refetched on item updateTag granularity too coarseUse { type, id } tags
Cache entry removed too soonkeepUnusedDataFor too lowIncrease the value
Refetch on every mountrefetchOnMountOrArgChange: trueUse a number or false
Stale data after navigationkeepUnusedDataFor expiredIncrease the lifetime

Real-World Examples

1. Set the API lifetime

keepUnusedDataFor: 60

2. Set the endpoint lifetime

getPosts: build.query<Post[], void>({ query: () => 'posts', keepUnusedDataFor: 300 })

3. Refetch on mount if older than 30 seconds

refetchOnMountOrArgChange: 30

4. Provide the list tag

providesTags: [{ type: 'Post', id: 'LIST' }]

5. Invalidate the list tag

invalidatesTags: [{ type: 'Post', id: 'LIST' }]

6. Provide the item tag

providesTags: (result, error, id) => [{ type: 'Post', id }]

7. Invalidate the item tag

invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }]

8. Manual refetch

const { refetch } = useGetPostsQuery()

9. Manual invalidation

api.util.invalidateTags(['Post'])

10. Optimistic update

api.util.updateQueryData('getPosts', undefined, (draft) => { draft.push(newPost) })

Visual

The Cache Lifetime

┌──────────────────────────────────────────────────────────────┐
│  THE LIFETIME TIMER STARTS WHEN THE LAST SUBSCRIBER UNMOUNTS│
│                                                              │
│  Component mounts → subscribes to the query                  │
│       │                                                      │
│       ▼                                                      │
│  Data is cached and kept alive                               │
│       │                                                      │
│       ▼                                                      │
│  Component unmounts → no subscribers                         │
│       │                                                      │
│       ▼                                                      │
│  keepUnusedDataFor timer starts (default 60s)                │
│       │                                                      │
│       ├── Component remounts before expiry → cached data     │
│       │                                                      │
│       └── Timer expires → cache entry removed                │
│             → next mount fetches again                       │
└──────────────────────────────────────────────────────────────┘

Tag Invalidation

┌──────────────────────────────────────────────────────────────┐
│  THE QUERY PROVIDES; THE MUTATION INVALIDATES                │
│                                                              │
│  getPosts query:                                             │
│  └── providesTags: ['Post', { type: 'Post', id: 'LIST' }]    │
│                                                              │
│  addPost mutation:                                           │
│  └── invalidatesTags: [{ type: 'Post', id: 'LIST' }]         │
│                                                              │
│  updatePost mutation:                                        │
│  └── invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }]│
│                                                              │
│  When addPost succeeds:                                      │
│  → the LIST tag is invalidated                               │
│  → getPosts is refetched                                     │
│                                                              │
│  When updatePost succeeds:                                   │
│  → the item tag is invalidated                               │
│  → getPost(id) is refetched                                  │
└──────────────────────────────────────────────────────────────┘

Tag Granularity

┌──────────────────────────────────────────────────────────────┐
│  COARSE VS GRANULAR TAGS                                     │
│                                                              │
│  Coarse (string tag):                                        │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ providesTags: ['Post']                                 │  │
│  │ invalidatesTags: ['Post']                              │  │
│  │ → any invalidation refetches every query that provides │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  Granular (object tag):                                      │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ providesTags: (result) => result.map(({ id }) =>       │  │
│  │   ({ type: 'Post', id }))                              │  │
│  │ invalidatesTags: (result, error, { id }) =>            │  │
│  │   [{ type: 'Post', id }]                               │  │
│  │ → one item's invalidation refetches only that item     │  │
│  └────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Default lifetime60 seconds
Lifetime optionkeepUnusedDataFor
Refetch optionrefetchOnMountOrArgChange
Query tagprovidesTags
Mutation taginvalidatesTags
Tag formsString, object, function
Manual refetchrefetch() from the hook
Manual invalidationapi.util.invalidateTags
Direct cache updateapi.util.updateQueryData
Reset the cacheapi.util.resetApiState

Key takeaways:

  • RTK Query caching has two mechanisms: lifetime and tags. The keepUnusedDataFor option controls how long an unused entry survives, and the providesTags/invalidatesTags pair controls when the data is refetched . The two mechanisms are independent, and both must be configured for the cache to behave correctly.
  • The lifetime timer starts when the last subscriber unmounts. The default is 60 seconds. A component that remounts before the timer expires receives the cached data immediately, and a component that remounts after the timer expires fetches the data again .
  • The refetchOnMountOrArgChange option controls the mount behavior. A value of true refetches on every mount, a number refetches if the data is older than that many seconds, and false uses the cached data regardless of its age.
  • The tag granularity determines the invalidation scope. A string tag applies to the whole endpoint, and an object tag applies to a specific item. The providesTags function form returns a tag for each item, and the invalidatesTags function form returns the tag for the affected item .
  • The addPost mutation invalidates the list tag, and the updatePost mutation invalidates the item tag. The list tag refetches the collection, and the item tag refetches the specific post. The choice depends on what the mutation affects.
  • The api.util methods provide manual control. The invalidateTags method triggers the same invalidation as the mutation’s invalidatesTags option, the updateQueryData method updates the cache directly, and the resetApiState method clears the entire cache.

Remember: RTK Query caching is a combination of the lifetime and the tags. The keepUnusedDataFor option controls how long the data survives, and the providesTags/invalidatesTags pair controls when it is refreshed. The tag granularity determines whether an invalidation refetches one item or the whole list. The refetchOnMountOrArgChange option controls the mount behavior. The api.util methods provide the manual control. The result is a cache that keeps the data alive long enough for navigation, refetches it when the server changes, and avoids the unbounded memory growth of a cache that never expires.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!