Learn Redux - Advanced RTK Query
Series/Learn Redux/Episode 17
Episode 17 of 23

Learn Redux - Advanced RTK Query

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.

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

Introduction

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.

Pagination and Infinite Queries

Manual Pagination with Page State

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:

JSPagination dengan useLazyGetPostsQuery
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.

Infinite Scroll with createApi

For endless lists, create an endpoint that accepts a cursor and keeps using the cache as an accumulator:

JSEndpoint paginated dengan cursor
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.

GraphQL and Streaming Updates

GraphQL with graphql-request

RTK Query doesn't limit the protocol. For GraphQL, write a custom baseQuery that uses graphql-request:

Install client GraphQL
npm install graphql-request graphql
JSGraphQL baseQuery
import { 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.

Streaming Updates

For real-time updates, such as stock prices or notifications, combine a subscription with a cache update via updateQueryData:

JSStreaming ke cache RTK Query
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.

Prioritized Prefetching

Loading Data Before Navigation

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:

JSPrefetch berprioritas
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.

Codegen and Custom baseQuery

Generating Endpoints from OpenAPI

@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:

Install dan jalankan codegen
npm install -D @rtk-query/codegen-openapi
npx @rtk-query/codegen-openapi openapi-config.ts

The 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.

Custom baseQuery for Auth Refresh

A baseQuery is also the right place to handle 401. Catch the error, refresh the token, then retry the original request:

JSbaseQuery dengan refresh token
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.

Conclusion

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:

  • Pagination uses the argument as the cache key; infinite scroll is managed by merge and forceRefetch.
  • GraphQL uses a custom baseQuery built on graphql-request.
  • Streaming updates use util.updateQueryData against the existing cache.
  • prefetch with ifOlderThan controls data-fetching priority.
  • @rtk-query/codegen-openapi generates endpoints and types from OpenAPI.
  • A custom baseQuery can handle multi-API and token refresh automatically.

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.

Learn Redux - Advanced RTK Query | Learn Redux