ReactRTK Query

RTK Query

Every real application fetches data. Without a dedicated tool, you end up writing the same loading/error/cache boilerplate dozens of times — and inevitably getting it wrong somewhere. RTK Query is a powerful data-fetching and caching layer built directly into Redux Toolkit. It generates React hooks automatically from your API definition and handles caching, deduplication, background refetching, and cache invalidation out of the box.

Core Concepts
  • createApi — defines the API: base URL, endpoints (queries and mutations).

  • Query endpoint — reads data. Generates a useGetXxxQuery hook.

  • Mutation endpoint — writes data. Generates a useAddXxxMutation hook.

  • providesTags / invalidatesTags — cache invalidation: a mutation can invalidate a query's cache, triggering an automatic refetch.

  • fetchBaseQuery — a thin fetch wrapper that handles base URL and headers. Swap it for axios or any custom function.

Creating an API Slice

createApi defines all your endpoints in one place. Here is a complete CRUD API for a users resource:

TS
// src/features/users/usersApi.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

interface User {
  id: number
  name: string
  email: string
}

interface NewUser {
  name: string
  email: string
}

export const usersApi = createApi({
  reducerPath: 'usersApi',   // key in the Redux store
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),

  // tagTypes declares the cache tags this API uses for invalidation
  tagTypes: ['User'],

  endpoints: builder => ({

    // ── Queries (read data) ──────────────────────────────────────────
    getUsers: builder.query<User[], void>({
      query: () => '/users',
      // This query's cache is tagged as 'User' + each user's id.
      // Any mutation that invalidatesTags(['User']) will refetch this.
      providesTags: (result) =>
        result
          ? [
              ...result.map(u => ({ type: 'User' as const, id: u.id })),
              { type: 'User', id: 'LIST' },
            ]
          : [{ type: 'User', id: 'LIST' }],
    }),

    getUserById: builder.query<User, number>({
      query: (id) => `/users/${id}`,
      providesTags: (_result, _err, id) => [{ type: 'User', id }],
    }),

    // ── Mutations (write data) ────────────────────────────────────────
    addUser: builder.mutation<User, NewUser>({
      query: (newUser) => ({
        url: '/users',
        method: 'POST',
        body: newUser,
      }),
      // Invalidate the LIST tag so getUsers refetches automatically
      invalidatesTags: [{ type: 'User', id: 'LIST' }],
    }),

    updateUser: builder.mutation<User, Partial<User> & Pick<User, 'id'>>({
      query: ({ id, ...patch }) => ({
        url: `/users/${id}`,
        method: 'PATCH',
        body: patch,
      }),
      // Invalidate this specific user's cache entry
      invalidatesTags: (_result, _err, { id }) => [{ type: 'User', id }],
    }),

    deleteUser: builder.mutation<void, number>({
      query: (id) => ({
        url: `/users/${id}`,
        method: 'DELETE',
      }),
      invalidatesTags: (_result, _err, id) => [
        { type: 'User', id },
        { type: 'User', id: 'LIST' },
      ],
    }),
  }),
})

// Auto-generated hooks — one per endpoint
export const {
  useGetUsersQuery,
  useGetUserByIdQuery,
  useAddUserMutation,
  useUpdateUserMutation,
  useDeleteUserMutation,
} = usersApi
Registering the API with the Store

TS
// src/store/index.ts
import { configureStore } from '@reduxjs/toolkit'
import { usersApi } from '../features/users/usersApi'

export const store = configureStore({
  reducer: {
    // RTK Query manages its own slice in the Redux store
    [usersApi.reducerPath]: usersApi.reducer,
  },
  // setupListeners enables refetchOnFocus and refetchOnReconnect
  middleware: getDefault =>
    getDefault().concat(usersApi.middleware),
})

// Optional: enable automatic refetch when the window regains focus
import { setupListeners } from '@reduxjs/toolkit/query'
setupListeners(store.dispatch)
Using Query Hooks

TSX
import { useGetUsersQuery } from './usersApi'

function UserList() {
  const {
    data: users,
    isLoading,
    isFetching,   // true during background refetch (data still shown)
    isError,
    error,
    refetch,      // manually trigger a refetch
  } = useGetUsersQuery()

  if (isLoading) return <p>Loading users…</p>
  if (isError)   return <p>Error: {String(error)}</p>

  return (
    <>
      {isFetching && <span>Refreshing…</span>}
      <ul>
        {users?.map(user => (
          <li key={user.id}>{user.name} — {user.email}</li>
        ))}
      </ul>
      <button onClick={refetch}>Refresh</button>
    </>
  )
}
Note
isLoading is true only on the first load (no cached data). isFetching is true any time a request is in flight, including background refetches. Show a subtle "refreshing" indicator for isFetching rather than a full loading spinner.
Using Mutation Hooks

TSX
import { useState } from 'react'
import { useAddUserMutation, useDeleteUserMutation } from './usersApi'

function AddUserForm() {
  const [name, setName]   = useState('')
  const [email, setEmail] = useState('')
  const [addUser, { isLoading, isError }] = useAddUserMutation()

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    try {
      await addUser({ name, email }).unwrap()  // throws on error
      setName('')
      setEmail('')
      // getUsers refetches automatically because addUser invalidates the LIST tag
    } catch (err) {
      console.error('Failed to add user:', err)
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={name}  onChange={e => setName(e.target.value)}  placeholder="Name" />
      <input value={email} onChange={e => setEmail(e.target.value)} placeholder="Email" />
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Adding…' : 'Add User'}
      </button>
      {isError && <p>Failed to add user.</p>}
    </form>
  )
}

function UserRow({ user }) {
  const [deleteUser] = useDeleteUserMutation()

  return (
    <li>
      {user.name}
      <button onClick={() => deleteUser(user.id)}>Delete</button>
    </li>
  )
}
Polling and Auto-Refetch

TSX
// Poll every 30 seconds
const { data } = useGetUsersQuery(undefined, {
  pollingInterval: 30_000,
})

// Skip the query entirely (useful for conditional fetching)
const { data } = useGetUserByIdQuery(userId, {
  skip: !userId,
})

// Refetch when the window regains focus (requires setupListeners)
const { data } = useGetUsersQuery(undefined, {
  refetchOnFocus: true,
})

// Keep previous data while fetching new data (no loading flash on refetch)
const { data, isFetching } = useGetUsersQuery(page, {
  keepPreviousData: true,
})
Optimistic Updates

For a snappy UI, update the cache optimistically before the server confirms. RTK Query supports this with onQueryStarted:

TS
updateUser: builder.mutation<User, Partial<User> & Pick<User, 'id'>>({
  query: ({ id, ...patch }) => ({
    url: `/users/${id}`,
    method: 'PATCH',
    body: patch,
  }),
  async onQueryStarted({ id, ...patch }, { dispatch, queryFulfilled }) {
    // Optimistically update the cache
    const patchResult = dispatch(
      usersApi.util.updateQueryData('getUsers', undefined, draft => {
        const user = draft.find(u => u.id === id)
        if (user) Object.assign(user, patch)
      })
    )

    try {
      await queryFulfilled   // wait for server confirmation
    } catch {
      // Server rejected — roll back the optimistic update
      patchResult.undo()
    }
  },
})
RTK Query vs TanStack Query

RTK Query

TanStack Query

Setup

Integrated into Redux store

Standalone, no Redux needed

Best when

Already using Redux Toolkit

No Redux, or non-Redux projects

Bundle size

Included in RTK (no extra dep)

Separate package (~13 KB gz)

Cache invalidation

Tag-based (explicit)

Key-based (invalidateQueries)

Optimistic updates

onQueryStarted + updateQueryData

onMutate + context rollback

DevTools

Redux DevTools

Dedicated TanStack Query DevTools

Warning
Don't use both RTK Query and TanStack Query in the same project for the same purpose — pick one. RTK Query if you're already using Redux; TanStack Query otherwise.
When RTK Query Is the Right Choice
  • Your app already uses Redux Toolkit for other state.

  • You want cache, loading state, background refetch, and invalidation with zero extra dependencies.

  • You need Redux DevTools to inspect cached server data alongside your client state.

  • You want a single createApi definition to serve as the source of truth for your API surface.

Tip
Start with RTK Query's generated hooks as-is. The automatically generated cache invalidation (providesTags / invalidatesTags) handles the hardest data-freshness problems automatically — you rarely need optimistic updates for basic CRUD.