Chapter 43 β RTK Query with TypeScript
π Definition
RTK Query is Redux Toolkit's built-in data-fetching and caching layer. You describe your API endpoints once with createApi; it generates typed React hooks (useGetUsersQuery, useCreateUserMutation) and handles loading state, caching, de-duplication, re-fetching and cache invalidation for you.
π Why RTK Query Instead of Thunks for Data Fetching?
| Concern | Hand-written thunk | RTK Query |
|---|---|---|
| Loading / error flags | Write per slice | isLoading, isFetching, isError, error |
| Caching | DIY | Automatic, keyed by endpoint + argument |
| Two components need the same data | Two requests (unless you add logic) | One request, shared cache |
| Refetch after a mutation | Manually dispatch again | invalidatesTags β auto refetch |
| Polling, refetch on focus/reconnect | DIY | Options |
| Cache cleanup | Never (memory grows) | Unused data removed after 60s (keepUnusedDataFor) |
Server state (data owned by the backend) β client state (cart, theme). RTK Query is for the first; slices are for the second.
π» Code Example β Defining the API
// services/api.ts
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import type { RootState } from "../app/store";
import type { Paginated, PublicUser, CreateUserDto, UpdateUserDto } from "@app/shared";
export interface Task {
id: string;
title: string;
done: boolean;
ownerId: string;
createdAt: string;
}
export const api = createApi({
reducerPath: "api",
baseQuery: fetchBaseQuery({
baseUrl: "/api",
credentials: "include", // send the refresh-token cookie
prepareHeaders: (headers, { getState }) => {
const token = (getState() as RootState).auth.accessToken;
if (token) headers.set("Authorization", `Bearer ${token}`);
return headers;
},
}),
tagTypes: ["User", "Task"],
endpoints: (builder) => ({
// β result type β argument type
getUsers: builder.query<Paginated<PublicUser>, { page: number; search?: string }>({
query: ({ page, search }) => ({ url: "/users", params: { page, search } }),
providesTags: (result) =>
result
? [...result.items.map((u) => ({ type: "User" as const, id: u.id })), { type: "User", id: "LIST" }]
: [{ type: "User", id: "LIST" }],
}),
getUser: builder.query<PublicUser, string>({
query: (id) => `/users/${id}`,
// Unwrap our { success, data } envelope so components get the entity directly
transformResponse: (res: { success: true; data: PublicUser }) => res.data,
providesTags: (_r, _e, id) => [{ type: "User", id }],
}),
createUser: builder.mutation<PublicUser, CreateUserDto>({
query: (body) => ({ url: "/users", method: "POST", body }),
invalidatesTags: [{ type: "User", id: "LIST" }], // refetch any user list
}),
updateUser: builder.mutation<PublicUser, { id: string } & UpdateUserDto>({
query: ({ id, ...patch }) => ({ url: `/users/${id}`, method: "PATCH", body: patch }),
invalidatesTags: (_r, _e, { id }) => [{ type: "User", id }],
}),
deleteUser: builder.mutation<void, string>({
query: (id) => ({ url: `/users/${id}`, method: "DELETE" }),
invalidatesTags: (_r, _e, id) => [{ type: "User", id }, { type: "User", id: "LIST" }],
}),
}),
});
export const {
useGetUsersQuery,
useGetUserQuery,
useCreateUserMutation,
useUpdateUserMutation,
useDeleteUserMutation,
} = api;Register it in the store (Chapter 23): add [api.reducerPath]: api.reducer and .concat(api.middleware). Call setupListeners(store.dispatch) once to enable refetchOnFocus / refetchOnReconnect.
π How Tags Work
getUsers({page:1}) provides [User:a, User:b, User:LIST]
getUser("a") provides [User:a]
updateUser({id:"a"}) invalidates [User:a]
β getUser("a") refetches
β getUsers({page:1}) refetches (it also provided User:a)
createUser(...) invalidates [User:LIST]
β only list queries refetchπ» Code Example β Queries in Components
import { useState } from "react";
import { skipToken } from "@reduxjs/toolkit/query/react";
import { useGetUsersQuery, useGetUserQuery } from "../../services/api";
export function UserList() {
const [page, setPage] = useState(1);
const { data, isLoading, isFetching, isError, error, refetch } = useGetUsersQuery(
{ page },
{ refetchOnMountOrArgChange: 30 }, // refetch if cached data is older than 30s
);
if (isLoading) return <p>Loadingβ¦</p>; // first load only
if (isError) return <ErrorBox error={error} onRetry={refetch} />;
return (
<>
{isFetching && <small>Updatingβ¦</small>} {/* background refetch */}
<ul>{data?.items.map((u) => <li key={u.id}>{u.name}</li>)}</ul>
<button disabled={page === 1} onClick={() => setPage((p) => p - 1)}>Prev</button>
<button onClick={() => setPage((p) => p + 1)}>Next</button>
</>
);
}
export function UserDetails({ id }: { id?: string }) {
// skipToken: don't run the query until we have an id (typed alternative to { skip: true })
const { data: user } = useGetUserQuery(id ?? skipToken);
return user ? <h2>{user.name}</h2> : null;
}isLoading = no data yet; isFetching = any request in flight (including background refetch). Showing a full spinner on isFetching makes pagination flicker.
π» Code Example β Typed Error Handling
import type { FetchBaseQueryError } from "@reduxjs/toolkit/query";
import type { SerializedError } from "@reduxjs/toolkit";
export function isFetchBaseQueryError(e: unknown): e is FetchBaseQueryError {
return typeof e === "object" && e != null && "status" in e;
}
export function errorMessage(e: FetchBaseQueryError | SerializedError | undefined): string {
if (!e) return "";
if (isFetchBaseQueryError(e)) {
// our Express error envelope: { success:false, code, message }
const data = e.data as { message?: string } | undefined;
return data?.message ?? `Request failed (${String(e.status)})`;
}
return e.message ?? "Unexpected error";
}π» Code Example β Mutations
import { useCreateUserMutation } from "../../services/api";
import { errorMessage } from "../../services/errors"; // helper from the previous example
export function NewUserForm() {
const [createUser, { isLoading, error }] = useCreateUserMutation();
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formEl = e.currentTarget; // capture now: currentTarget is null after an await
const form = new FormData(formEl);
try {
await createUser({
name: String(form.get("name")),
email: String(form.get("email")),
password: String(form.get("password")),
}).unwrap(); // throws on error
formEl.reset();
} catch {
/* error is also available from the hook state */
}
}
return (
<form onSubmit={handleSubmit}>
<input name="name" required />
<input name="email" type="email" required />
<input name="password" type="password" minLength={8} required />
<button disabled={isLoading}>{isLoading ? "Savingβ¦" : "Create"}</button>
{error && <p role="alert">{errorMessage(error)}</p>}
</form>
);
}π» Code Example β Optimistic Update (onQueryStarted)
toggleTask: builder.mutation<Task, { id: string; done: boolean; page: number }>({
query: ({ id, done }) => ({ url: `/tasks/${id}`, method: "PATCH", body: { done } }),
async onQueryStarted({ id, done, page }, { dispatch, queryFulfilled }) {
// 1. Patch the cached list immediately
const patch = dispatch(
api.util.updateQueryData("getTasks", { page }, (draft) => {
const task = draft.items.find((t) => t.id === id);
if (task) task.done = done;
}),
);
try {
await queryFulfilled; // 2. Wait for the server
} catch {
patch.undo(); // 3. Roll back if it failed
}
},
}),π» Code Example β Automatic Token Refresh (baseQueryWithReauth)
When the access token expires the API returns 401. Wrap the base query to call /auth/refresh once, then retry.
import { fetchBaseQuery, type BaseQueryFn, type FetchArgs, type FetchBaseQueryError } from "@reduxjs/toolkit/query/react";
import { tokenRefreshed, loggedOut } from "../features/auth/authSlice";
const rawBaseQuery = fetchBaseQuery({ /* same config as above */ baseUrl: "/api", credentials: "include" });
let refreshing: Promise<boolean> | null = null; // share one refresh between parallel 401s
export const baseQueryWithReauth: BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError> =
async (args, api, extraOptions) => {
let result = await rawBaseQuery(args, api, extraOptions);
if (result.error?.status === 401) {
refreshing ??= (async () => {
const r = await rawBaseQuery({ url: "/auth/refresh", method: "POST" }, api, extraOptions);
const data = r.data as { accessToken: string } | undefined;
if (data) api.dispatch(tokenRefreshed(data.accessToken));
else api.dispatch(loggedOut());
return Boolean(data);
})().finally(() => { refreshing = null; });
if (await refreshing) result = await rawBaseQuery(args, api, extraOptions); // retry once
}
return result;
};
// createApi({ baseQuery: baseQueryWithReauth, ... })π» Code Example β Splitting a Large API (injectEndpoints)
// features/tasks/tasksApi.ts
import { api } from "../../services/api";
export const tasksApi = api.injectEndpoints({
endpoints: (builder) => ({
getTasks: builder.query<Paginated<Task>, { page: number }>({
query: ({ page }) => `/tasks?page=${page}`,
providesTags: [{ type: "Task", id: "LIST" }],
}),
}),
});
export const { useGetTasksQuery } = tasksApi;Each feature folder owns its endpoints, but they share one cache, middleware and base query.
π RTK Query vs TanStack Query
| RTK Query | TanStack Query | |
|---|---|---|
| Lives in | Redux store (DevTools, same middleware) | Its own QueryClient |
| API definition | Central createApi, endpoints declared upfront |
Inline useQuery({ queryKey, queryFn }) per call |
| Invalidation | Tags (providesTags / invalidatesTags) |
Query keys (invalidateQueries) |
| Codegen | From OpenAPI (@rtk-query/codegen-openapi) |
Community tools |
| Pick when | You already use Redux | No Redux, or you want maximum flexibility |
π Real-World Impact
- Typical CRUD screens lose 60β80% of their data-fetching code: no slice, no thunks, no loading flags.
- Tag invalidation means "I edited a user but the list still shows the old name" bugs disappear.
- A single shared refresh promise stops a burst of parallel 401s from triggering 10 refresh calls.
π― Likely Interview Questions
- What is RTK Query and when would you use it? β Data fetching + caching layer in RTK; for server state in Redux apps.
- How does caching work? β Cache entry per endpoint + serialised argument; components subscribe; unused entries are removed after
keepUnusedDataFor(60s). - Explain
providesTagsandinvalidatesTags. β Queries label their data; mutations declare which labels became stale β matching queries refetch. isLoadingvsisFetching? β First load without data vs any in-flight request.- How do you do optimistic updates? β
onQueryStarted+api.util.updateQueryData+patch.undo()on failure. - How do you attach a JWT and refresh it? β
prepareHeadersfor the header; a custombaseQuerythat catches 401, refreshes once and retries. - How do you conditionally skip a query? β Pass
skipTokenor{ skip: true }. - RTK Query vs TanStack Query? β See table; same ideas, different home for the cache.
β Async Logic with Thunks | Index | Next: Selectors & Normalised State β