Learn Redux - RTK Query: Mutations & Cache Invalidation
Episode 9 of 23

Learn Redux - RTK Query: Mutations & Cache Invalidation

This episode covers the data-writing side of RTK Query: endpoints.mutation and the useXMutation hook for POST, PUT, and DELETE, optimistic updates via onQueryStarted, and cache synchronization with providesTags and invalidatesTags so data lists stay consistent after every mutation.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

Episode 8 only covered reading data. But real applications also have to create, update, and delete — and after that the UI must show the latest data. Episode 9 closes that gap with mutations and cache invalidation: two RTK Query mechanisms that keep server state in sync after write operations.

A common problem in manual applications: after a successful POST, the list on screen stays stale because nothing triggers a re-fetch. RTK Query solves this declaratively. You simply state that a query depends on a tag, and the mutation marks that tag stale — the query is then re-fetched automatically.

Creating a Mutation Endpoint

Builder Mutation for Writing

endpoints.mutation handles operations that change data on the server: POST, PUT, PATCH, and DELETE. Like queries, mutations use a query that can adjust the method and body:

JSEndpoint mutation pada postsApi
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"
import type { Post } from "./postsApi"
 
export const postsApi = createApi({
  reducerPath: "postsApi",
  baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
  endpoints: (builder) => ({
    getPosts: builder.query<Post[], void>({
      query: () => "posts",
      providesTags: (result) =>
        result
          ? [...result.map(({ id }) => ({ type: "Post" as const, id })),
             { type: "Post" as const, id: "LIST" }]
          : [{ type: "Post" as const, id: "LIST" }],
    }),
    addPost: builder.mutation<Post, Partial<Post>>({
      query: (body) => ({
        url: "posts",
        method: "POST",
        body,
      }),
      invalidatesTags: [{ type: "Post", id: "LIST" }],
    }),
  }),
})

Note the providesTags on getPosts: the query result registers one tag per post with its own id, plus a special "LIST" tag. Meanwhile addPost declares invalidatesTags for "LIST". In other words: every time addPost succeeds, every query that provides the Post:LIST tag is re-fetched.

The useXMutation Hook

From a mutation endpoint, RTK Query generates a useAddPostMutation hook. Unlike query hooks, mutation hooks return a [trigger, result] pair:

JSMemakai useAddPostMutation
import { useAddPostMutation } from "../services/postsApi"
 
export function CreatePost() {
  const [addPost, { isLoading }] = useAddPostMutation()
 
  const submit = async (title: string, body: string) => {
    try {
      await addPost({ title, body }).unwrap()
      console.log("Post berhasil dibuat")
    } catch (error) {
      console.error("Gagal membuat post", error)
    }
  }
 
  return <button disabled={isLoading} onClick={() => submit("Halo", "Isi")}>Kirim</button>
}

addPost returns a promise; calling .unwrap() turns it into a regular promise so errors can be caught with try/catch. Without .unwrap(), errors are only stored in the result.

Optimistic and Pessimistic Updates

Updating Locally Before Server Confirmation

A pessimistic update waits for the server response before updating the cache. An optimistic update does the opposite: the cache is updated immediately with a placeholder result, then rolled back if the server rejects it. Both are written inside onQueryStarted:

JSOptimistic update dengan onQueryStarted
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"
import type { Post } from "./postsApi"
 
updatePost: builder.mutation<Post, Post>({
  query: ({ id, ...patch }) => ({
    url: `posts/${id}`,
    method: "PATCH",
    body: patch,
  }),
  onQueryStarted: async ({ id, ...patch }, { dispatch, queryFulfilled }) => {
    const patchResult = dispatch(
      postsApi.util.updateQueryData("getPostById", id, (draft) => {
        Object.assign(draft, patch)
      }),
    )
    try {
      await queryFulfilled
    } catch {
      patchResult.undo()
    }
  },
}),

updateQueryData modifies cache data with an Immer draft. If queryFulfilled rejects (the request failed), patchResult.undo() restores the cache to its pre-optimistic state. The UI feels fast because it's updated before the server replies.

Warning

Optimistic updates are best for operations that almost always succeed, such as likes or preference changes. For operations that frequently fail, like large file uploads, pessimistic updates are safer because they don't discard local data.

Precise Cache Invalidation

Granular Tags with IDs

Besides the "LIST" tag, you can create per-item granular invalidation. This matters when a single post is updated: only that post's detail is re-fetched, while other lists that don't touch that data keep their cache:

JSInvalidasi granular per post
updatePost: builder.mutation<Post, Partial<Post> & { id: number }>({
  query: ({ id, ...patch }) => ({
    url: `posts/${id}`,
    method: "PATCH",
    body: patch,
  }),
  invalidatesTags: (_result, _error, arg) => [{ type: "Post", id: arg.id }],
}),
 
deletePost: builder.mutation<{ success: boolean }, number>({
  query: (id) => ({
    url: `posts/${id}`,
    method: "DELETE",
  }),
  invalidatesTags: (_result, _error, id) => [{ type: "Post", id }],
}),

When updatePost succeeds, only the Post:{id} tag is invalidated — not the whole list. The getPostById(id) query is re-fetched, while getPosts keeps using its old cache. Combining granular tags with "LIST" strikes a balance between accuracy and network efficiency.

Controlling Whole-List Re-fetch

There are times when a list genuinely needs to be fully reloaded after a write operation, for example after creating a new item that changes the ordering. The trick is simple: also add the "LIST" tag to that mutation's invalidatesTags:

JSInvalidasi LIST setelah operasi menulis
addPost: builder.mutation<Post, Partial<Post>>({
  query: (body) => ({ url: "posts", method: "POST", body }),
  invalidatesTags: [
    { type: "Post", id: "LIST" },
  ],
}),

After addPost finishes, every query whose providesTags includes Post:LIST marks its cache as stale and triggers a re-fetch. Make sure every query that depends on that list really provides the same tag, otherwise the sync won't happen.

Conclusion

Mutations complete RTK Query into a full data-fetching solution: endpoints.mutation handles write operations, the useXMutation hook provides the trigger and status, onQueryStarted enables cancellable optimistic updates, and tags form the declarative contract for invalidation. As a result, consistency between the server and the UI is maintained automatically without manual synchronization code.

Key takeaways:

  • endpoints.mutation is used for POST, PUT, PATCH, and DELETE.
  • Mutation hooks return a trigger/result pair; use .unwrap() for error handling.
  • providesTags declares which queries provide data named by a tag.
  • invalidatesTags marks tags stale so related queries are re-fetched.
  • onQueryStarted with updateQueryData enables undoable optimistic updates.
  • Combine per-id granular tags with the "LIST" tag for efficient invalidation.

In the next episode, episode 10 shifts focus to data normalization — you'll learn createEntityAdapter to manage collections with automated CRUD, the built-in selectAll and selectById selectors, plus sorting, which are useful for slices that store many entities.