This episode dives into advanced RTK Query features: pagination and infinite queries, GraphQL with graphql-request, streaming updates, prioritized prefetching, OpenAPI codegen, and a custom baseQuery for multi-API and automatic auth refresh when tokens expire.

Episodes 8 and 9 laid the RTK Query foundation: queries, mutations, and invalidation. Real apps demand more: endless lists, real-time data, GraphQL, and API clients derived from a specification. Episode 17 covers these advanced features one by one.
We'll use pagination and infinite queries, talk to a GraphQL server, receive streaming updates, prioritize prefetching, generate endpoints from OpenAPI, and write a custom baseQuery that handles token refresh automatically.
The most common approach: keep the page number in local state and pass it as a query argument. The RTK Query cache automatically separates data per page:
export function PaginatedList() {
const [page, setPage] = useState(1)
const [fetchPosts, { data, isFetching }] = useLazyGetPostsQuery()
return (
<>
<button onClick={() => fetchPosts(page)}>Muat halaman {page}</button>
<button disabled={isFetching} onClick={() => setPage((p) => p + 1)}>
Halaman berikutnya
</button>
</>
)
}useLazyGetPostsQuery gives full control over when a request runs. The page argument becomes the cache key, so going back to page 2 doesn't send a new request — the data is still stored.
For endless lists, create an endpoint that accepts a cursor and keeps using the cache as an accumulator:
getPostsPaginated: builder.query<PaginatedPosts, number>({
query: (page) => `posts?page=${page}&limit=10`,
serializeQueryArgs: ({ endpointName }) => endpointName,
merge: (currentCache, newItems) => {
currentCache.items.push(...newItems.items)
currentCache.nextPage = newItems.nextPage
},
forceRefetch: ({ currentArg, previousArg }) => currentArg !== previousArg,
}),merge appends new pages to the same cache, and forceRefetch makes sure a page is re-fetched when the argument changes. Together they produce infinite scroll without manual list-management code.
RTK Query doesn't limit the protocol. For GraphQL, write a custom baseQuery that uses graphql-request:
npm install graphql-request graphqlimport { GraphQLClient } from "graphql-request"
const gqlClient = new GraphQLClient("https://api.example.com/graphql", {
headers: { authorization: "Bearer " + getAccessToken() },
})
export const graphqlBaseQuery = async ({ document, variables }) => {
try {
const data = await gqlClient.request(document, variables)
return { data }
} catch (error) {
return { error }
}
}A baseQuery only needs to return { data } or { error }. GraphQL is handled entirely by graphql-request, while caching and lifecycle stay with RTK Query.
For real-time updates, such as stock prices or notifications, combine a subscription with a cache update via updateQueryData:
export const pricesApi = createApi({
reducerPath: "pricesApi",
baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
endpoints: (builder) => ({
getPrices: builder.query<Price[], void>({
query: () => "prices",
}),
}),
})
export const onPriceTick = (price: Price) => {
pricesApi.util.updateQueryData("getPrices", undefined, (draft) => {
const idx = draft.findIndex((p) => p.symbol === price.symbol)
if (idx !== -1) draft[idx] = price
})
}Call onPriceTick from a WebSocket connection. The data mutates the cache directly without a re-request — the UI renders the latest price automatically.
RTK Query provides prefetch, which sends the request earlier. The priority is set through the second argument: force to always re-fetch, ifOlderThan to skip a still-fresh cache:
export const prefetchNextPage = () => {
postsApi.util.prefetch("getPostsPaginated", 3, { ifOlderThan: 30000 })
}ifOlderThan: 30000 means the request is only sent if the cache is older than 30 seconds. It's a simple way to prepare the next page's data while the user hovers over the next button.
@rtk-query/codegen-openapi generates entire endpoints from an OpenAPI specification — reducing the work of writing queries by hand and keeping types in sync with the API:
npm install -D @rtk-query/codegen-openapi
npx @rtk-query/codegen-openapi openapi-config.tsThe openapi-config.ts file names the specification file, the output location, and the API client name. The result: a TypeScript file containing createApi, every endpoint, and the data types — ready to use directly.
A baseQuery is also the right place to handle 401. Catch the error, refresh the token, then retry the original request:
import { fetchBaseQuery, type BaseQueryFn } from "@reduxjs/toolkit/query/react"
const rawBase = fetchBaseQuery({ baseUrl: "/api" })
export const baseQueryWithAuth: BaseQueryFn<
string | FetchArgs,
unknown,
FetchBaseQueryError
> = async (args, api, extraOptions) => {
let result = await rawBase(args, api, extraOptions)
if (result.error?.status === 401) {
const refresh = await rawBase("/auth/refresh", api, extraOptions)
if (refresh.data) {
setAccessToken(refresh.data.accessToken)
result = await rawBase(args, api, extraOptions)
}
}
return result
}When the token expires, the middleware performs a refresh once and then retries the original request. Every endpoint using this baseQuery automatically gets the same behavior — without writing retry logic in each hook.
Tip
Combine a custom baseQuery with the built-in fetchBaseQuery whenever possible: it already handles headers, query params, form data, and normalized errors. Just layer auth logic on top when it's truly needed.
RTK Query is mature for real-world scenarios: pagination and infinite scroll via merge and forceRefetch, GraphQL through a custom baseQuery, streaming with updateQueryData, prioritized prefetching, endpoints generated from OpenAPI, and centralized token refresh. These features make RTK Query a data layer that barely needs to be written by hand.
Key takeaways:
merge and forceRefetch.util.updateQueryData against the existing cache.prefetch with ifOlderThan controls data-fetching priority.@rtk-query/codegen-openapi generates endpoints and types from OpenAPI.In the next episode, episode 18 covers testing — you'll test reducers purely, test async thunks with mocked fetch, render components with a test store using testing-library, and integrate MSW to mock APIs realistically.