[{"data":1,"prerenderedAt":1581},["ShallowReactive",2],{"article/how-to-use-rtk-query-the-right-scalable-way":3},{"_path":4,"_dir":5,"_draft":6,"_partial":6,"_locale":7,"title":8,"description":9,"featured":10,"author":11,"categories":12,"slug":13,"image":14,"imageAlt":24,"published":10,"draft":6,"createdAt":25,"updatedAt":26,"faqs":27,"body":43,"_type":1575,"_id":1576,"_source":1577,"_file":1578,"_stem":1579,"_extension":1580,"isInteractive":6,"interactiveConfig":-1},"/articles/react/how-to-use-rtk-query-the-right-scalable-way","react",false,"","How to Use RTK Query the Right Way: Scalable React Patterns","RTK Query patterns that scale: endpoint code splitting with injectEndpoints, precise cache tags, token refresh, TypeScript integration, and optimistic updates.",true,"Thomas Findlay","React, Redux, Javascript","how-to-use-rtk-query-the-right-scalable-way",[15,16,17,18,19,20,21,22,23],"/images/articles/how-to-use-rtk-query-the-right-scalable-way/react-redux-toolkit-how-to-use-rtk-query-the-right-scalable-way-640w.avif","/images/articles/how-to-use-rtk-query-the-right-scalable-way/react-redux-toolkit-how-to-use-rtk-query-the-right-scalable-way-1024w.avif","/images/articles/how-to-use-rtk-query-the-right-scalable-way/react-redux-toolkit-how-to-use-rtk-query-the-right-scalable-way-1920w.avif","/images/articles/how-to-use-rtk-query-the-right-scalable-way/react-redux-toolkit-how-to-use-rtk-query-the-right-scalable-way-640w.webp","/images/articles/how-to-use-rtk-query-the-right-scalable-way/react-redux-toolkit-how-to-use-rtk-query-the-right-scalable-way-1024w.webp","/images/articles/how-to-use-rtk-query-the-right-scalable-way/react-redux-toolkit-how-to-use-rtk-query-the-right-scalable-way-1920w.webp","/images/articles/how-to-use-rtk-query-the-right-scalable-way/react-redux-toolkit-how-to-use-rtk-query-the-right-scalable-way-640w.png","/images/articles/how-to-use-rtk-query-the-right-scalable-way/react-redux-toolkit-how-to-use-rtk-query-the-right-scalable-way-1024w.png","/images/articles/how-to-use-rtk-query-the-right-scalable-way/react-redux-toolkit-how-to-use-rtk-query-the-right-scalable-way-1920w.png","Diagram showing RTK Query endpoint code splitting and cache tag patterns in a scalable React app","2026-05-28T00:00:00","2026-05-29T00:00:00",[28,31,34,37,40],{"question":29,"answer":30},"What is RTK Query?","RTK Query is Redux Toolkit's built-in data-fetching and caching layer. It handles caching, loading states, background refetching, and cache invalidation automatically, removing the need to write repetitive data-fetching logic by hand. It generates typed React hooks for every endpoint you define.",{"question":32,"answer":33},"When should I use RTK Query instead of TanStack Query?","Use RTK Query when your app already uses Redux and you want data fetching integrated with your existing store and Redux DevTools. Use TanStack Query when you want a lighter solution without a Redux dependency, or when you need async state management that goes beyond API requests.",{"question":35,"answer":36},"What does injectEndpoints do in RTK Query?","injectEndpoints lets you add endpoints to an existing createApi base slice from separate feature files. Instead of defining every endpoint in one large file, each feature injects its own endpoints at runtime. The generated hooks are exported from the feature file directly, so no component ever imports from the base API.",{"question":38,"answer":39},"How do RTK Query cache tags work?","Queries declare what data they provide using providesTags, and mutations declare what they invalidate with invalidatesTags. When a mutation runs, any query whose tags overlap with the invalidated tags automatically refetches. Using object tags like { type: 'Users', id: userId } instead of plain strings enables surgical invalidation. Only the affected record refetches, not every query for that type.",{"question":41,"answer":42},"What is the difference between skip and skipToken in RTK Query?","Both prevent a query from running, but skipToken is type-safe. With skip, TypeScript still sees the argument as string | undefined, requiring a manual cast. With skipToken, the hook accepts string | typeof skipToken, so no cast is needed and TypeScript enforces the condition at the type level.",{"type":44,"children":45,"toc":1560},"root",[46,54,59,145,150,157,170,175,180,189,201,206,217,229,242,250,259,280,285,293,302,310,319,386,392,403,422,427,435,444,473,493,502,507,513,529,578,605,610,618,627,645,652,661,666,674,683,726],{"type":47,"tag":48,"props":49,"children":50},"element","p",{},[51],{"type":52,"value":53},"text","RTK Query is Redux Toolkit's built-in data-fetching and caching layer. It takes care of the repetitive work around server state: caching, loading states, background refetching, and cache invalidation. The API looks straightforward at first glance, but the decisions we make early (where to define endpoints, how to structure cache tags, where to handle auth errors) either pay off or cause real friction as an app grows.",{"type":47,"tag":48,"props":55,"children":56},{},[57],{"type":52,"value":58},"In this article, we will cover:",{"type":47,"tag":60,"props":61,"children":62},"ul",{},[63,76,89,94,99,110,115,134],{"type":47,"tag":64,"props":65,"children":66},"li",{},[67,69],{"type":52,"value":68},"Splitting API endpoints across feature files with ",{"type":47,"tag":70,"props":71,"children":73},"code",{"className":72},[],[74],{"type":52,"value":75},"injectEndpoints",{"type":47,"tag":64,"props":77,"children":78},{},[79,81,87],{"type":52,"value":80},"Writing a custom ",{"type":47,"tag":70,"props":82,"children":84},{"className":83},[],[85],{"type":52,"value":86},"baseQuery",{"type":52,"value":88}," that handles token refresh automatically",{"type":47,"tag":64,"props":90,"children":91},{},[92],{"type":52,"value":93},"Dropping RTK Query into an existing Axios-based API layer",{"type":47,"tag":64,"props":95,"children":96},{},[97],{"type":52,"value":98},"Using cache tags precisely, only refetching what actually changed",{"type":47,"tag":64,"props":100,"children":101},{},[102,104],{"type":52,"value":103},"Transforming server responses at the endpoint level with ",{"type":47,"tag":70,"props":105,"children":107},{"className":106},[],[108],{"type":52,"value":109},"transformResponse",{"type":47,"tag":64,"props":111,"children":112},{},[113],{"type":52,"value":114},"TypeScript integration for type-safe endpoints and cache",{"type":47,"tag":64,"props":116,"children":117},{},[118,120,126,128],{"type":52,"value":119},"Conditionally skipping queries with ",{"type":47,"tag":70,"props":121,"children":123},{"className":122},[],[124],{"type":52,"value":125},"skip",{"type":52,"value":127}," and ",{"type":47,"tag":70,"props":129,"children":131},{"className":130},[],[132],{"type":52,"value":133},"skipToken",{"type":47,"tag":64,"props":135,"children":136},{},[137,139],{"type":52,"value":138},"Optimistic updates with ",{"type":47,"tag":70,"props":140,"children":142},{"className":141},[],[143],{"type":52,"value":144},"onQueryStarted",{"type":47,"tag":48,"props":146,"children":147},{},[148],{"type":52,"value":149},"By the end, we will have a set of patterns that work together and can be adopted one at a time as an app grows.",{"type":47,"tag":151,"props":152,"children":154},"h2",{"id":153},"the-problem-with-one-big-api-slice",[155],{"type":52,"value":156},"The problem with one big API slice",{"type":47,"tag":48,"props":158,"children":159},{},[160,162,168],{"type":52,"value":161},"Most RTK Query tutorials start by defining every endpoint inside a single ",{"type":47,"tag":70,"props":163,"children":165},{"className":164},[],[166],{"type":52,"value":167},"createApi",{"type":52,"value":169}," call. That works fine at small scale. The problem shows up later.",{"type":47,"tag":48,"props":171,"children":172},{},[173],{"type":52,"value":174},"When every feature imports from the same file, unrelated parts of the app become coupled to each other in a way that is not obvious from the imports. The entire slice loads upfront, even endpoints a user may never trigger. And the file itself grows to hundreds of lines (users, posts, comments, settings, and notifications all sitting next to each other) until it becomes the one file in the codebase that nobody wants to touch.",{"type":47,"tag":48,"props":176,"children":177},{},[178],{"type":52,"value":179},"Here is what that looks like in practice:",{"type":47,"tag":48,"props":181,"children":182},{},[183],{"type":47,"tag":184,"props":185,"children":186},"strong",{},[187],{"type":52,"value":188},"src/api/api.ts",{"type":47,"tag":190,"props":191,"children":196},"pre",{"className":192,"code":194,"language":195,"meta":7},[193],"language-typescript","import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'\n\nexport const api = createApi({\n  reducerPath: 'api',\n  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),\n  tagTypes: ['Users', 'Posts', 'Comments', 'Settings'],\n  endpoints: (builder) => ({\n    getUsers: builder.query\u003CUser[], void>({\n      query: () => '/users',\n    }),\n    getUserById: builder.query\u003CUser, string>({\n      query: (id) => `/users/${id}`,\n    }),\n    createUser: builder.mutation\u003CUser, Partial\u003CUser>>({\n      query: (body) => ({ url: '/users', method: 'POST', body }),\n    }),\n    getPosts: builder.query\u003CPost[], void>({\n      query: () => '/posts',\n    }),\n    getPostById: builder.query\u003CPost, string>({\n      query: (id) => `/posts/${id}`,\n    }),\n    createPost: builder.mutation\u003CPost, Partial\u003CPost>>({\n      query: (body) => ({ url: '/posts', method: 'POST', body }),\n    }),\n    getComments: builder.query\u003CComment[], string>({\n      query: (postId) => `/posts/${postId}/comments`,\n    }),\n    getSettings: builder.query\u003CSettings, void>({\n      query: () => '/settings',\n    }),\n    updateSettings: builder.mutation\u003CSettings, Partial\u003CSettings>>({\n      query: (body) => ({ url: '/settings', method: 'PATCH', body }),\n    }),\n    // ... 30 more endpoints\n  }),\n})\n","typescript",[197],{"type":47,"tag":70,"props":198,"children":199},{"__ignoreMap":7},[200],{"type":52,"value":194},{"type":47,"tag":48,"props":202,"children":203},{},[204],{"type":52,"value":205},"This is the file we are trying to avoid. Every new feature adds more lines here. Every change to the users feature touches the same file as changes to posts and settings. The structure that starts simple becomes a maintenance problem the moment the app is more than a few features wide.",{"type":47,"tag":151,"props":207,"children":209},{"id":208},"code-splitting-with-injectendpoints",[210,212],{"type":52,"value":211},"Code splitting with ",{"type":47,"tag":70,"props":213,"children":215},{"className":214},[],[216],{"type":52,"value":75},{"type":47,"tag":48,"props":218,"children":219},{},[220,222,227],{"type":52,"value":221},"RTK Query's solution to this is ",{"type":47,"tag":70,"props":223,"children":225},{"className":224},[],[226],{"type":52,"value":75},{"type":52,"value":228},". Instead of putting every endpoint in one place, we define a lean base API with shared configuration, and each feature injects its own endpoints into that base from its own file. The features stay isolated; the shared setup lives in one place.",{"type":47,"tag":48,"props":230,"children":231},{},[232,234,240],{"type":52,"value":233},"Let's start by creating the base API. Notice that the ",{"type":47,"tag":70,"props":235,"children":237},{"className":236},[],[238],{"type":52,"value":239},"endpoints",{"type":52,"value":241}," property returns an empty object. We are deliberately leaving it empty here:",{"type":47,"tag":48,"props":243,"children":244},{},[245],{"type":47,"tag":184,"props":246,"children":247},{},[248],{"type":52,"value":249},"src/api/base.api.slice.ts",{"type":47,"tag":190,"props":251,"children":254},{"className":252,"code":253,"language":195,"meta":7},[193],"import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'\n\nexport const baseApi = createApi({\n  reducerPath: 'api',\n  baseQuery: fetchBaseQuery({\n    baseUrl: '/api',\n    prepareHeaders: (headers, { getState }) => {\n      const token = (getState() as RootState).auth.token\n      if (token) {\n        headers.set('Authorization', `Bearer ${token}`)\n      }\n      return headers\n    },\n  }),\n  tagTypes: ['Users', 'Posts', 'Comments', 'Settings'],\n  endpoints: () => ({}),\n})\n",[255],{"type":47,"tag":70,"props":256,"children":257},{"__ignoreMap":7},[258],{"type":52,"value":253},{"type":47,"tag":48,"props":260,"children":261},{},[262,264,270,272,278],{"type":52,"value":263},"The ",{"type":47,"tag":70,"props":265,"children":267},{"className":266},[],[268],{"type":52,"value":269},"tagTypes",{"type":52,"value":271}," are declared centrally here because RTK Query needs to know them upfront for cache invalidation to work across all feature slices. The ",{"type":47,"tag":70,"props":273,"children":275},{"className":274},[],[276],{"type":52,"value":277},"prepareHeaders",{"type":52,"value":279}," function attaches the auth token to every outgoing request automatically.",{"type":47,"tag":48,"props":281,"children":282},{},[283],{"type":52,"value":284},"Now let's see how each feature adds its own endpoints:",{"type":47,"tag":48,"props":286,"children":287},{},[288],{"type":47,"tag":184,"props":289,"children":290},{},[291],{"type":52,"value":292},"src/features/users/users.api.slice.ts",{"type":47,"tag":190,"props":294,"children":297},{"className":295,"code":296,"language":195,"meta":7},[193],"import { baseApi } from '@/api/base.api.slice'\n\nconst URLS = {\n  getUsers: '/users',\n  getUserById: (id: string) => `/users/${id}`,\n}\n\nconst usersApi = baseApi.injectEndpoints({\n  endpoints: (builder) => ({\n    getUsers: builder.query\u003CUser[], void>({\n      query: () => ({ url: URLS.getUsers, method: 'GET' }),\n    }),\n    getUserById: builder.query\u003CUser, string>({\n      query: (id) => ({ url: URLS.getUserById(id), method: 'GET' }),\n    }),\n    createUser: builder.mutation\u003CUser, Omit\u003CUser, 'id'>>({\n      query: (data) => ({ url: URLS.getUsers, method: 'POST', body: data }),\n    }),\n  }),\n})\n\nexport const {\n  useGetUsersQuery,\n  useGetUserByIdQuery,\n  useCreateUserMutation,\n} = usersApi\n",[298],{"type":47,"tag":70,"props":299,"children":300},{"__ignoreMap":7},[301],{"type":52,"value":296},{"type":47,"tag":48,"props":303,"children":304},{},[305],{"type":47,"tag":184,"props":306,"children":307},{},[308],{"type":52,"value":309},"src/features/posts/posts.api.slice.ts",{"type":47,"tag":190,"props":311,"children":314},{"className":312,"code":313,"language":195,"meta":7},[193],"import { baseApi } from '@/api/base.api.slice'\n\nconst URLS = {\n  getPosts: '/posts',\n  getPostById: (id: string) => `/posts/${id}`,\n}\n\nconst postsApi = baseApi.injectEndpoints({\n  endpoints: (builder) => ({\n    getPosts: builder.query\u003CPost[], void>({\n      query: () => ({ url: URLS.getPosts, method: 'GET' }),\n    }),\n    getPostById: builder.query\u003CPost, string>({\n      query: (id) => ({ url: URLS.getPostById(id), method: 'GET' }),\n    }),\n  }),\n})\n\nexport const { useGetPostsQuery, useGetPostByIdQuery } = postsApi\n",[315],{"type":47,"tag":70,"props":316,"children":317},{"__ignoreMap":7},[318],{"type":52,"value":313},{"type":47,"tag":48,"props":320,"children":321},{},[322,324,329,331,337,339,344,346,352,354,360,362,368,370,376,378,384],{"type":52,"value":323},"Let's digest what's happening here. ",{"type":47,"tag":70,"props":325,"children":327},{"className":326},[],[328],{"type":52,"value":75},{"type":52,"value":330}," takes the same ",{"type":47,"tag":70,"props":332,"children":334},{"className":333},[],[335],{"type":52,"value":336},"builder",{"type":52,"value":338}," API as ",{"type":47,"tag":70,"props":340,"children":342},{"className":341},[],[343],{"type":52,"value":167},{"type":52,"value":345}," and merges the new endpoints into ",{"type":47,"tag":70,"props":347,"children":349},{"className":348},[],[350],{"type":52,"value":351},"baseApi",{"type":52,"value":353}," at runtime. The generated hooks (",{"type":47,"tag":70,"props":355,"children":357},{"className":356},[],[358],{"type":52,"value":359},"useGetUsersQuery",{"type":52,"value":361},", ",{"type":47,"tag":70,"props":363,"children":365},{"className":364},[],[366],{"type":52,"value":367},"useCreateUserMutation",{"type":52,"value":369},", and so on) are exported directly from the feature file. No component ever needs to import from the base API. When the users feature changes, we edit ",{"type":47,"tag":70,"props":371,"children":373},{"className":372},[],[374],{"type":52,"value":375},"users.api.slice.ts",{"type":52,"value":377},". When posts change, we edit ",{"type":47,"tag":70,"props":379,"children":381},{"className":380},[],[382],{"type":52,"value":383},"posts.api.slice.ts",{"type":52,"value":385},". The files stay focused and the coupling is gone.",{"type":47,"tag":151,"props":387,"children":389},{"id":388},"the-base-query-handling-auth-and-errors-in-one-place",[390],{"type":52,"value":391},"The base query: handling auth and errors in one place",{"type":47,"tag":48,"props":393,"children":394},{},[395,396,401],{"type":52,"value":263},{"type":47,"tag":70,"props":397,"children":399},{"className":398},[],[400],{"type":52,"value":277},{"type":52,"value":402}," option we saw above attaches an auth token to every request, but it does not handle what happens when the server rejects the token. A 401 response means the token has expired. In most apps, the right response is to refresh the token silently and retry the original request.",{"type":47,"tag":48,"props":404,"children":405},{},[406,407,412,414,420],{"type":52,"value":263},{"type":47,"tag":70,"props":408,"children":410},{"className":409},[],[411],{"type":52,"value":86},{"type":52,"value":413}," is the single function RTK Query calls for every request, which makes it the right place to handle this, rather than repeating the logic in each endpoint individually. We wrap the standard ",{"type":47,"tag":70,"props":415,"children":417},{"className":416},[],[418],{"type":52,"value":419},"fetchBaseQuery",{"type":52,"value":421}," in a custom function that intercepts 401 responses and attempts a token refresh before giving up.",{"type":47,"tag":48,"props":423,"children":424},{},[425],{"type":52,"value":426},"Here is that custom base query:",{"type":47,"tag":48,"props":428,"children":429},{},[430],{"type":47,"tag":184,"props":431,"children":432},{},[433],{"type":52,"value":434},"src/api/baseQueryWithReauth.ts",{"type":47,"tag":190,"props":436,"children":439},{"className":437,"code":438,"language":195,"meta":7},[193],"import {\n  fetchBaseQuery,\n  BaseQueryFn,\n  FetchArgs,\n  FetchBaseQueryError,\n} from '@reduxjs/toolkit/query/react'\nimport { setToken, logout } from '@/features/auth/authSlice'\nimport type { RootState } from '@/store'\n\nconst baseQuery = fetchBaseQuery({\n  baseUrl: '/api',\n  prepareHeaders: (headers, { getState }) => {\n    const token = (getState() as RootState).auth.token\n    if (token) {\n      headers.set('Authorization', `Bearer ${token}`)\n    }\n    return headers\n  },\n})\n\nexport const baseQueryWithReauth: BaseQueryFn\u003C\n  string | FetchArgs,\n  unknown,\n  FetchBaseQueryError\n> = async (args, api, extraOptions) => {\n  let result = await baseQuery(args, api, extraOptions)\n\n  if (result.error?.status === 401) {\n    const refreshResult = await baseQuery(\n      { url: '/auth/refresh', method: 'POST' },\n      api,\n      extraOptions,\n    )\n\n    if (refreshResult.data) {\n      api.dispatch(setToken(refreshResult.data as string))\n      result = await baseQuery(args, api, extraOptions)\n    } else {\n      api.dispatch(logout())\n    }\n  }\n\n  return result\n}\n",[440],{"type":47,"tag":70,"props":441,"children":442},{"__ignoreMap":7},[443],{"type":52,"value":438},{"type":47,"tag":48,"props":445,"children":446},{},[447,449,455,457,463,465,471],{"type":52,"value":448},"Let's walk through what happens when a request fails with a 401. First, we run the original request and store the result. If that result has a 401 error, we immediately make a second request to ",{"type":47,"tag":70,"props":450,"children":452},{"className":451},[],[453],{"type":52,"value":454},"/auth/refresh",{"type":52,"value":456},". If the refresh succeeds and we get new token data back, we dispatch ",{"type":47,"tag":70,"props":458,"children":460},{"className":459},[],[461],{"type":52,"value":462},"setToken",{"type":52,"value":464}," to update the Redux store, then retry the original request, this time with the new token in the headers. If the refresh itself fails, we dispatch ",{"type":47,"tag":70,"props":466,"children":468},{"className":467},[],[469],{"type":52,"value":470},"logout()",{"type":52,"value":472}," to clear the auth state. In every case, we return a result, so the calling hook always gets something to work with.",{"type":47,"tag":48,"props":474,"children":475},{},[476,478,484,486,491],{"type":52,"value":477},"Then we use it in ",{"type":47,"tag":70,"props":479,"children":481},{"className":480},[],[482],{"type":52,"value":483},"base.api.slice.ts",{"type":52,"value":485}," instead of the inline ",{"type":47,"tag":70,"props":487,"children":489},{"className":488},[],[490],{"type":52,"value":419},{"type":52,"value":492},":",{"type":47,"tag":190,"props":494,"children":497},{"className":495,"code":496,"language":195,"meta":7},[193],"import { createApi } from '@reduxjs/toolkit/query/react'\nimport { baseQueryWithReauth } from './baseQueryWithReauth'\n\nexport const baseApi = createApi({\n  reducerPath: 'api',\n  baseQuery: baseQueryWithReauth,\n  tagTypes: ['Users', 'Posts', 'Comments', 'Settings'],\n  endpoints: () => ({}),\n})\n",[498],{"type":47,"tag":70,"props":499,"children":500},{"__ignoreMap":7},[501],{"type":52,"value":496},{"type":47,"tag":48,"props":503,"children":504},{},[505],{"type":52,"value":506},"Every endpoint now gets token refresh behaviour for free, with no per-endpoint handling required.",{"type":47,"tag":151,"props":508,"children":510},{"id":509},"integrating-rtk-query-with-an-existing-axios-api-layer",[511],{"type":52,"value":512},"Integrating RTK Query with an Existing Axios API Layer",{"type":47,"tag":48,"props":514,"children":515},{},[516,518,527],{"type":52,"value":517},"If we have already built the API layer from ",{"type":47,"tag":519,"props":520,"children":524},"a",{"href":521,"rel":522},"https://theroadtoenterprise.com/books/react-the-road-to-enterprise/typescript",[523],"nofollow",[525],{"type":52,"value":526},"React - The Road To Enterprise",{"type":52,"value":528},", we do not need to replace it. RTK Query and that API layer solve different problems and they work well together.",{"type":47,"tag":48,"props":530,"children":531},{},[532,534,539,541,547,548,554,555,561,562,568,570,576],{"type":52,"value":533},"The book's ",{"type":47,"tag":70,"props":535,"children":537},{"className":536},[],[538],{"type":52,"value":188},{"type":52,"value":540}," configures an Axios instance and wraps it in typed ",{"type":47,"tag":70,"props":542,"children":544},{"className":543},[],[545],{"type":52,"value":546},"get",{"type":52,"value":361},{"type":47,"tag":70,"props":549,"children":551},{"className":550},[],[552],{"type":52,"value":553},"post",{"type":52,"value":361},{"type":47,"tag":70,"props":556,"children":558},{"className":557},[],[559],{"type":52,"value":560},"patch",{"type":52,"value":361},{"type":47,"tag":70,"props":563,"children":565},{"className":564},[],[566],{"type":52,"value":567},"put",{"type":52,"value":569},", and ",{"type":47,"tag":70,"props":571,"children":573},{"className":572},[],[574],{"type":52,"value":575},"delete",{"type":52,"value":577}," methods. Feature API files import that wrapper and define endpoint functions with centralized URL constants. This gives us a single place to update base URLs, consistent request handling, and typed responses.",{"type":47,"tag":48,"props":579,"children":580},{},[581,583,588,590,596,598,603],{"type":52,"value":582},"RTK Query adds what that layer does not provide: caching, request deduplication, background refetching, and automatic loading and error state per component. The integration point is a custom ",{"type":47,"tag":70,"props":584,"children":586},{"className":585},[],[587],{"type":52,"value":86},{"type":52,"value":589}," that delegates to the book's ",{"type":47,"tag":70,"props":591,"children":593},{"className":592},[],[594],{"type":52,"value":595},"api",{"type":52,"value":597}," object instead of using ",{"type":47,"tag":70,"props":599,"children":601},{"className":600},[],[602],{"type":52,"value":419},{"type":52,"value":604},".",{"type":47,"tag":48,"props":606,"children":607},{},[608],{"type":52,"value":609},"Let's see how that adapter looks:",{"type":47,"tag":48,"props":611,"children":612},{},[613],{"type":47,"tag":184,"props":614,"children":615},{},[616],{"type":52,"value":617},"src/api/axiosBaseQuery.ts",{"type":47,"tag":190,"props":619,"children":622},{"className":620,"code":621,"language":195,"meta":7},[193],"import { AxiosError } from 'axios'\nimport api from './api'\n\ntype AxiosBaseQueryArgs = {\n  url: string\n  method: 'get' | 'post' | 'put' | 'patch' | 'delete'\n  data?: unknown\n  params?: Record\u003Cstring, unknown>\n}\n\ntype AxiosBaseQueryError = {\n  status: number | undefined\n  data: unknown\n}\n\nexport const axiosBaseQuery = async ({\n  url,\n  method,\n  data,\n  params,\n}: AxiosBaseQueryArgs): Promise\u003C\n  { data: unknown } | { error: AxiosBaseQueryError }\n> => {\n  try {\n    let result\n    if (method === 'get' || method === 'delete') {\n      result = await api[method](url, { params })\n    } else {\n      result = await api[method](url, data, { params })\n    }\n    return { data: result.data }\n  } catch (error) {\n    const axiosError = error as AxiosError\n    return {\n      error: {\n        status: axiosError.response?.status,\n        data: axiosError.response?.data,\n      },\n    }\n  }\n}\n",[623],{"type":47,"tag":70,"props":624,"children":625},{"__ignoreMap":7},[626],{"type":52,"value":621},{"type":47,"tag":48,"props":628,"children":629},{},[630,632,637,639,644],{"type":52,"value":631},"Pass it to ",{"type":47,"tag":70,"props":633,"children":635},{"className":634},[],[636],{"type":52,"value":167},{"type":52,"value":638}," in place of ",{"type":47,"tag":70,"props":640,"children":642},{"className":641},[],[643],{"type":52,"value":419},{"type":52,"value":492},{"type":47,"tag":48,"props":646,"children":647},{},[648],{"type":47,"tag":184,"props":649,"children":650},{},[651],{"type":52,"value":249},{"type":47,"tag":190,"props":653,"children":656},{"className":654,"code":655,"language":195,"meta":7},[193],"import { createApi } from '@reduxjs/toolkit/query/react'\nimport { axiosBaseQuery } from './axiosBaseQuery'\n\nexport const baseApi = createApi({\n  reducerPath: 'api',\n  baseQuery: axiosBaseQuery,\n  tagTypes: ['Users', 'Posts'],\n  endpoints: () => ({}),\n})\n",[657],{"type":47,"tag":70,"props":658,"children":659},{"__ignoreMap":7},[660],{"type":52,"value":655},{"type":47,"tag":48,"props":662,"children":663},{},[664],{"type":52,"value":665},"The feature API file keeps its URL constants and gains RTK Query's endpoint pattern:",{"type":47,"tag":48,"props":667,"children":668},{},[669],{"type":47,"tag":184,"props":670,"children":671},{},[672],{"type":52,"value":673},"src/api/users.api.slice.ts",{"type":47,"tag":190,"props":675,"children":678},{"className":676,"code":677,"language":195,"meta":7},[193],"import { baseApi } from './base.api.slice'\n\nconst URLS = {\n  getUsers: '/users',\n  createUser: '/users',\n}\n\nexport type User = {\n  id: string\n  name: string\n  email: string\n}\n\nconst usersApi = baseApi.injectEndpoints({\n  endpoints: (builder) => ({\n    getUsers: builder.query\u003CUser[], void>({\n      query: () => ({ url: URLS.getUsers, method: 'get' }),\n    }),\n    createUser: builder.mutation\u003CUser, Omit\u003CUser, 'id'>>({\n      query: (data) => ({\n        url: URLS.createUser,\n        method: 'post',\n        data,\n      }),\n    }),\n  }),\n})\n\nexport const { useGetUsersQuery, useCreateUserMutation } = usersApi\n",[679],{"type":47,"tag":70,"props":680,"children":681},{"__ignoreMap":7},[682],{"type":52,"value":677},{"type":47,"tag":48,"props":684,"children":685},{},[686,687,693,695,701,703,708,710,716,718,724],{"type":52,"value":263},{"type":47,"tag":70,"props":688,"children":690},{"className":689},[],[691],{"type":52,"value":692},"axiosBaseQuery",{"type":52,"value":694}," adapter is the bridge between the two worlds. It accepts the same ",{"type":47,"tag":70,"props":696,"children":698},{"className":697},[],[699],{"type":52,"value":700},"{ url, method, data, params }",{"type":52,"value":702}," shape that our feature endpoints produce, calls the appropriate method on the existing ",{"type":47,"tag":70,"props":704,"children":706},{"className":705},[],[707],{"type":52,"value":595},{"type":52,"value":709}," object, and returns either ",{"type":47,"tag":70,"props":711,"children":713},{"className":712},[],[714],{"type":52,"value":715},"{ data }",{"type":52,"value":717}," on success or ",{"type":47,"tag":70,"props":719,"children":721},{"className":720},[],[722],{"type":52,"value":723},"{ error }",{"type":52,"value":725}," on failure, the shape RTK Query expects. Teams migrating incrementally can move one feature at a time. Routes that have not yet migrated keep calling the feature API functions directly. Routes that have migrated use the generated hooks. Both talk to the same Axios instance, so interceptors, auth headers, and base URL config apply everywhere without duplication.",{"type":47,"tag":727,"props":728,"children":729},"base-react-promo",{},[730,742,748,769,789,843,848,855,864,923,951,957,969,990,999,1036,1057,1066,1078,1084,1104,1112,1121,1129,1142,1151,1171,1180,1195,1207,1216,1233,1238,1250,1259,1270,1279,1331,1350,1360,1379,1407,1412,1421,1525,1530,1534],{"type":47,"tag":48,"props":731,"children":732},{},[733,735,740],{"type":52,"value":734},"The API layer pattern is covered in depth in ",{"type":47,"tag":519,"props":736,"children":738},{"href":521,"rel":737},[523],[739],{"type":52,"value":526},{"type":52,"value":741},", along with request cancellation, error logging, and handling async state without flickering spinners.",{"type":47,"tag":151,"props":743,"children":745},{"id":744},"cache-tags-the-pattern-that-actually-works",[746],{"type":52,"value":747},"Cache tags: the pattern that actually works",{"type":47,"tag":48,"props":749,"children":750},{},[751,753,759,761,767],{"type":52,"value":752},"Cache invalidation is where RTK Query saves the most time, but the straightforward approach has a subtle problem. Using string tags like ",{"type":47,"tag":70,"props":754,"children":756},{"className":755},[],[757],{"type":52,"value":758},"invalidatesTags: ['Users']",{"type":52,"value":760}," works, but it invalidates the entire ",{"type":47,"tag":70,"props":762,"children":764},{"className":763},[],[765],{"type":52,"value":766},"Users",{"type":52,"value":768}," tag, meaning every cached user query refetches, including queries for individual users that were not affected by the mutation.",{"type":47,"tag":48,"props":770,"children":771},{},[772,774,780,781,787],{"type":52,"value":773},"RTK Query's solution is to use objects with a ",{"type":47,"tag":70,"props":775,"children":777},{"className":776},[],[778],{"type":52,"value":779},"type",{"type":52,"value":127},{"type":47,"tag":70,"props":782,"children":784},{"className":783},[],[785],{"type":52,"value":786},"id",{"type":52,"value":788}," field instead. Before we look at the code, let's think through the logic:",{"type":47,"tag":60,"props":790,"children":791},{},[792,803,814,826,838],{"type":47,"tag":64,"props":793,"children":794},{},[795,797],{"type":52,"value":796},"A query that fetches all users provides ",{"type":47,"tag":70,"props":798,"children":800},{"className":799},[],[801],{"type":52,"value":802},"{ type: 'Users', id: 'LIST' }",{"type":47,"tag":64,"props":804,"children":805},{},[806,808],{"type":52,"value":807},"A query that fetches a single user provides ",{"type":47,"tag":70,"props":809,"children":811},{"className":810},[],[812],{"type":52,"value":813},"{ type: 'Users', id: userId }",{"type":47,"tag":64,"props":815,"children":816},{},[817,819,824],{"type":52,"value":818},"A mutation that creates a user invalidates only ",{"type":47,"tag":70,"props":820,"children":822},{"className":821},[],[823],{"type":52,"value":802},{"type":52,"value":825},", triggering a refetch of the list but not individual user queries",{"type":47,"tag":64,"props":827,"children":828},{},[829,831,836],{"type":52,"value":830},"A mutation that updates a user invalidates ",{"type":47,"tag":70,"props":832,"children":834},{"className":833},[],[835],{"type":52,"value":813},{"type":52,"value":837},", refetching only that user",{"type":47,"tag":64,"props":839,"children":840},{},[841],{"type":52,"value":842},"A mutation that deletes a user invalidates both, since the list needs to update and the cached entry for that user is no longer valid",{"type":47,"tag":48,"props":844,"children":845},{},[846],{"type":52,"value":847},"Here is the full pattern for a users resource:",{"type":47,"tag":48,"props":849,"children":850},{},[851],{"type":47,"tag":184,"props":852,"children":853},{},[854],{"type":52,"value":292},{"type":47,"tag":190,"props":856,"children":859},{"className":857,"code":858,"language":195,"meta":7},[193],"import { baseApi } from '@/api/base.api.slice'\n\nexport type User = {\n  id: string\n  name: string\n  email: string\n}\n\nconst usersApi = baseApi.injectEndpoints({\n  endpoints: (builder) => ({\n    getUsers: builder.query\u003CUser[], void>({\n      query: () => ({ url: '/users', method: 'GET' }),\n      providesTags: (result) =>\n        result\n          ? [\n              ...result.map(({ id }) => ({ type: 'Users' as const, id })),\n              { type: 'Users' as const, id: 'LIST' },\n            ]\n          : [{ type: 'Users' as const, id: 'LIST' }],\n    }),\n\n    getUserById: builder.query\u003CUser, string>({\n      query: (id) => ({ url: `/users/${id}`, method: 'GET' }),\n      providesTags: (result, error, id) => [{ type: 'Users', id }],\n    }),\n\n    createUser: builder.mutation\u003CUser, Omit\u003CUser, 'id'>>({\n      query: (data) => ({ url: '/users', method: 'POST', body: data }),\n      invalidatesTags: [{ type: 'Users', id: 'LIST' }],\n    }),\n\n    updateUser: builder.mutation\u003CUser, Pick\u003CUser, 'id'> & Partial\u003CUser>>({\n      query: ({ id, ...data }) => ({\n        url: `/users/${id}`,\n        method: 'PATCH',\n        body: data,\n      }),\n      invalidatesTags: (result, error, { id }) => [{ type: 'Users', id }],\n    }),\n\n    deleteUser: builder.mutation\u003Cvoid, string>({\n      query: (id) => ({ url: `/users/${id}`, method: 'DELETE' }),\n      invalidatesTags: (result, error, id) => [\n        { type: 'Users', id },\n        { type: 'Users', id: 'LIST' },\n      ],\n    }),\n  }),\n})\n\nexport const {\n  useGetUsersQuery,\n  useGetUserByIdQuery,\n  useCreateUserMutation,\n  useUpdateUserMutation,\n  useDeleteUserMutation,\n} = usersApi\n",[860],{"type":47,"tag":70,"props":861,"children":862},{"__ignoreMap":7},[863],{"type":52,"value":858},{"type":47,"tag":48,"props":865,"children":866},{},[867,869,875,877,883,885,891,893,899,901,906,908,913,915,921],{"type":52,"value":868},"Let's look at the ",{"type":47,"tag":70,"props":870,"children":872},{"className":871},[],[873],{"type":52,"value":874},"providesTags",{"type":52,"value":876}," callback on ",{"type":47,"tag":70,"props":878,"children":880},{"className":879},[],[881],{"type":52,"value":882},"getUsers",{"type":52,"value":884}," more closely. When the query succeeds and ",{"type":47,"tag":70,"props":886,"children":888},{"className":887},[],[889],{"type":52,"value":890},"result",{"type":52,"value":892}," is available, it returns two things: an individual tag for each user in the result (",{"type":47,"tag":70,"props":894,"children":896},{"className":895},[],[897],{"type":52,"value":898},"{ type: 'Users', id }",{"type":52,"value":900},"), and a tag for the list as a whole (",{"type":47,"tag":70,"props":902,"children":904},{"className":903},[],[905],{"type":52,"value":802},{"type":52,"value":907},"). The fallback when ",{"type":47,"tag":70,"props":909,"children":911},{"className":910},[],[912],{"type":52,"value":890},{"type":52,"value":914}," is undefined provides just the ",{"type":47,"tag":70,"props":916,"children":918},{"className":917},[],[919],{"type":52,"value":920},"LIST",{"type":52,"value":922}," tag, so the query still participates in cache invalidation even if it failed.",{"type":47,"tag":48,"props":924,"children":925},{},[926,928,934,936,942,944,949],{"type":52,"value":927},"This means that if we update a specific user, ",{"type":47,"tag":70,"props":929,"children":931},{"className":930},[],[932],{"type":52,"value":933},"invalidatesTags",{"type":52,"value":935}," for ",{"type":47,"tag":70,"props":937,"children":939},{"className":938},[],[940],{"type":52,"value":941},"updateUser",{"type":52,"value":943}," targets only that user's ID tag. The list query won't refetch unless we explicitly invalidate ",{"type":47,"tag":70,"props":945,"children":947},{"className":946},[],[948],{"type":52,"value":920},{"type":52,"value":950},". Whether we want the list to refetch on an individual update depends on what the list shows. If the list displays the user's name and we changed their name, we probably want the list to update. If the list only shows IDs and we updated something else, the per-ID tags on the list query can be skipped. The pattern above is the conservative default that handles both cases.",{"type":47,"tag":151,"props":952,"children":954},{"id":953},"transforming-api-responses-with-transformresponse",[955],{"type":52,"value":956},"Transforming API Responses with transformResponse",{"type":47,"tag":48,"props":958,"children":959},{},[960,962,967],{"type":52,"value":961},"Servers often return data in a shape that does not match what the UI needs. Rather than scattering the transformation logic across components, ",{"type":47,"tag":70,"props":963,"children":965},{"className":964},[],[966],{"type":52,"value":109},{"type":52,"value":968}," lets us normalize the response at the endpoint level, so components always receive data in the shape they expect.",{"type":47,"tag":48,"props":970,"children":971},{},[972,974,980,982,988],{"type":52,"value":973},"Let's look at a common case: a server that wraps its response in a ",{"type":47,"tag":70,"props":975,"children":977},{"className":976},[],[978],{"type":52,"value":979},"data",{"type":52,"value":981}," envelope with metadata in a separate ",{"type":47,"tag":70,"props":983,"children":985},{"className":984},[],[986],{"type":52,"value":987},"meta",{"type":52,"value":989}," key.",{"type":47,"tag":190,"props":991,"children":994},{"className":992,"code":993,"language":195,"meta":7},[193],"getUsers: builder.query\u003C{ users: User[]; total: number }, void>({\n  query: () => ({ url: '/users', method: 'GET' }),\n  transformResponse: (response: {\n    data: { items: User[] }\n    meta: { total: number }\n  }) => ({\n    users: response.data.items,\n    total: response.meta.total,\n  }),\n}),\n",[995],{"type":47,"tag":70,"props":996,"children":997},{"__ignoreMap":7},[998],{"type":52,"value":993},{"type":47,"tag":48,"props":1000,"children":1001},{},[1002,1004,1010,1012,1018,1020,1026,1028,1034],{"type":52,"value":1003},"After this transformation, ",{"type":47,"tag":70,"props":1005,"children":1007},{"className":1006},[],[1008],{"type":52,"value":1009},"useGetUsersQuery()",{"type":52,"value":1011}," returns ",{"type":47,"tag":70,"props":1013,"children":1015},{"className":1014},[],[1016],{"type":52,"value":1017},"{ users, total }",{"type":52,"value":1019}," directly. The components that consume this hook never need to know that the server wraps its response in ",{"type":47,"tag":70,"props":1021,"children":1023},{"className":1022},[],[1024],{"type":52,"value":1025},"data.items",{"type":52,"value":1027}," or that pagination metadata lives in ",{"type":47,"tag":70,"props":1029,"children":1031},{"className":1030},[],[1032],{"type":52,"value":1033},"meta.total",{"type":52,"value":1035},". The shape the UI cares about is defined once, at the endpoint.",{"type":47,"tag":48,"props":1037,"children":1038},{},[1039,1041,1047,1049,1055],{"type":52,"value":1040},"Another common use is converting ",{"type":47,"tag":70,"props":1042,"children":1044},{"className":1043},[],[1045],{"type":52,"value":1046},"snake_case",{"type":52,"value":1048}," from the server to ",{"type":47,"tag":70,"props":1050,"children":1052},{"className":1051},[],[1053],{"type":52,"value":1054},"camelCase",{"type":52,"value":1056}," for the UI:",{"type":47,"tag":190,"props":1058,"children":1061},{"className":1059,"code":1060,"language":195,"meta":7},[193],"transformResponse: (response: { first_name: string; last_name: string; created_at: string }) => ({\n  firstName: response.first_name,\n  lastName: response.last_name,\n  createdAt: response.created_at,\n}),\n",[1062],{"type":47,"tag":70,"props":1063,"children":1064},{"__ignoreMap":7},[1065],{"type":52,"value":1060},{"type":47,"tag":48,"props":1067,"children":1068},{},[1069,1071,1076],{"type":52,"value":1070},"Keep ",{"type":47,"tag":70,"props":1072,"children":1074},{"className":1073},[],[1075],{"type":52,"value":109},{"type":52,"value":1077}," focused on shape, not business logic. If you find yourself doing filtering or sorting here, that belongs in the component or a selector.",{"type":47,"tag":151,"props":1079,"children":1081},{"id":1080},"typescript-integration",[1082],{"type":52,"value":1083},"TypeScript Integration",{"type":47,"tag":48,"props":1085,"children":1086},{},[1087,1089,1095,1096,1102],{"type":52,"value":1088},"RTK Query generates typed hooks automatically based on the types we pass to ",{"type":47,"tag":70,"props":1090,"children":1092},{"className":1091},[],[1093],{"type":52,"value":1094},"builder.query",{"type":52,"value":127},{"type":47,"tag":70,"props":1097,"children":1099},{"className":1098},[],[1100],{"type":52,"value":1101},"builder.mutation",{"type":52,"value":1103},". Getting those types right means we get correctly typed data, loading states, and error objects in components without any manual casting.",{"type":47,"tag":48,"props":1105,"children":1106},{},[1107],{"type":47,"tag":184,"props":1108,"children":1109},{},[1110],{"type":52,"value":1111},"Let's start with typing query and mutation arguments:",{"type":47,"tag":190,"props":1113,"children":1116},{"className":1114,"code":1115,"language":195,"meta":7},[193],"// Query: first generic is the return type, second is the argument type\nbuilder.query\u003CUser[], void>({  // void means no argument\n  query: () => ({ url: '/users', method: 'GET' }),\n})\n\nbuilder.query\u003CUser, string>({  // string argument (user ID)\n  query: (id) => ({ url: `/users/${id}`, method: 'GET' }),\n})\n\n// Mutation: first generic is the return type, second is the argument type\nbuilder.mutation\u003CUser, Omit\u003CUser, 'id'>>({\n  query: (data) => ({ url: '/users', method: 'POST', body: data }),\n})\n",[1117],{"type":47,"tag":70,"props":1118,"children":1119},{"__ignoreMap":7},[1120],{"type":52,"value":1115},{"type":47,"tag":48,"props":1122,"children":1123},{},[1124],{"type":47,"tag":184,"props":1125,"children":1126},{},[1127],{"type":52,"value":1128},"Let's look at typing the error shape:",{"type":47,"tag":48,"props":1130,"children":1131},{},[1132,1134,1140],{"type":52,"value":1133},"RTK Query's ",{"type":47,"tag":70,"props":1135,"children":1137},{"className":1136},[],[1138],{"type":52,"value":1139},"FetchBaseQueryError",{"type":52,"value":1141}," covers the standard case, but if our API returns a consistent error structure, we can narrow it in components:",{"type":47,"tag":190,"props":1143,"children":1146},{"className":1144,"code":1145,"language":195,"meta":7},[193],"type ApiError = {\n  status: number\n  data: {\n    message: string\n    code: string\n  }\n}\n\n// In a component\nconst [updateUser, { error }] = useUpdateUserMutation()\n\nif (error && 'status' in error) {\n  const apiError = error as ApiError\n  console.log(apiError.data.message)\n}\n",[1147],{"type":47,"tag":70,"props":1148,"children":1149},{"__ignoreMap":7},[1150],{"type":52,"value":1145},{"type":47,"tag":48,"props":1152,"children":1153},{},[1154,1156,1161,1163,1169],{"type":52,"value":1155},"If we are using the ",{"type":47,"tag":70,"props":1157,"children":1159},{"className":1158},[],[1160],{"type":52,"value":692},{"type":52,"value":1162}," from the previous section, our error shape comes from what we return in the ",{"type":47,"tag":70,"props":1164,"children":1166},{"className":1165},[],[1167],{"type":52,"value":1168},"catch",{"type":52,"value":1170}," block. Let's make the type explicit there and reuse it in components:",{"type":47,"tag":190,"props":1172,"children":1175},{"className":1173,"code":1174,"language":195,"meta":7},[193],"// axiosBaseQuery.ts\nexport type AxiosBaseQueryError = {\n  status: number | undefined\n  data: unknown\n}\n",[1176],{"type":47,"tag":70,"props":1177,"children":1178},{"__ignoreMap":7},[1179],{"type":52,"value":1174},{"type":47,"tag":48,"props":1181,"children":1182},{},[1183],{"type":47,"tag":184,"props":1184,"children":1185},{},[1186,1187,1193],{"type":52,"value":868},{"type":47,"tag":70,"props":1188,"children":1190},{"className":1189},[],[1191],{"type":52,"value":1192},"RootState",{"type":52,"value":1194}," type and store setup:",{"type":47,"tag":48,"props":1196,"children":1197},{},[1198,1200,1205],{"type":52,"value":1199},"We need to make sure our store type includes the ",{"type":47,"tag":70,"props":1201,"children":1203},{"className":1202},[],[1204],{"type":52,"value":595},{"type":52,"value":1206}," reducer so selectors and hooks have correct types:",{"type":47,"tag":190,"props":1208,"children":1211},{"className":1209,"code":1210,"language":195,"meta":7},[193],"// src/store.ts\nimport { configureStore } from '@reduxjs/toolkit'\nimport { baseApi } from '@/api/base.api.slice'\n\nexport const store = configureStore({\n  reducer: {\n    [baseApi.reducerPath]: baseApi.reducer,\n    // other reducers\n  },\n  middleware: (getDefaultMiddleware) =>\n    getDefaultMiddleware().concat(baseApi.middleware),\n})\n\nexport type RootState = ReturnType\u003Ctypeof store.getState>\nexport type AppDispatch = typeof store.dispatch\n",[1212],{"type":47,"tag":70,"props":1213,"children":1214},{"__ignoreMap":7},[1215],{"type":52,"value":1210},{"type":47,"tag":151,"props":1217,"children":1219},{"id":1218},"selective-fetching-with-skip-and-skiptoken",[1220,1222,1227,1228],{"type":52,"value":1221},"Selective fetching with ",{"type":47,"tag":70,"props":1223,"children":1225},{"className":1224},[],[1226],{"type":52,"value":125},{"type":52,"value":127},{"type":47,"tag":70,"props":1229,"children":1231},{"className":1230},[],[1232],{"type":52,"value":133},{"type":47,"tag":48,"props":1234,"children":1235},{},[1236],{"type":52,"value":1237},"Sometimes a query should not run until other data is available. The most common case is fetching data that depends on a value that might be undefined on first render, like a user ID from a selector that resolves asynchronously.",{"type":47,"tag":48,"props":1239,"children":1240},{},[1241,1243,1248],{"type":52,"value":1242},"We have two ways to handle this. The first is the ",{"type":47,"tag":70,"props":1244,"children":1246},{"className":1245},[],[1247],{"type":52,"value":125},{"type":52,"value":1249}," option:",{"type":47,"tag":190,"props":1251,"children":1254},{"className":1252,"code":1253,"language":195,"meta":7},[193],"const userId = useSelector(selectCurrentUserId)\n\nconst { data: posts } = useGetUserPostsQuery(userId as string, {\n  skip: !userId,\n})\n",[1255],{"type":47,"tag":70,"props":1256,"children":1257},{"__ignoreMap":7},[1258],{"type":52,"value":1253},{"type":47,"tag":48,"props":1260,"children":1261},{},[1262,1264,1269],{"type":52,"value":1263},"And ",{"type":47,"tag":70,"props":1265,"children":1267},{"className":1266},[],[1268],{"type":52,"value":133},{"type":52,"value":492},{"type":47,"tag":190,"props":1271,"children":1274},{"className":1272,"code":1273,"language":195,"meta":7},[193],"import { skipToken } from '@reduxjs/toolkit/query/react'\n\nconst userId = useSelector(selectCurrentUserId)\n\nconst { data: posts } = useGetUserPostsQuery(userId ?? skipToken)\n",[1275],{"type":47,"tag":70,"props":1276,"children":1277},{"__ignoreMap":7},[1278],{"type":52,"value":1273},{"type":47,"tag":48,"props":1280,"children":1281},{},[1282,1284,1290,1292,1298,1300,1306,1308,1314,1316,1321,1323,1329],{"type":52,"value":1283},"The difference is type safety. With ",{"type":47,"tag":70,"props":1285,"children":1287},{"className":1286},[],[1288],{"type":52,"value":1289},"skip: !userId",{"type":52,"value":1291},", we have to cast ",{"type":47,"tag":70,"props":1293,"children":1295},{"className":1294},[],[1296],{"type":52,"value":1297},"userId",{"type":52,"value":1299}," to ",{"type":47,"tag":70,"props":1301,"children":1303},{"className":1302},[],[1304],{"type":52,"value":1305},"string",{"type":52,"value":1307}," because TypeScript still sees it as ",{"type":47,"tag":70,"props":1309,"children":1311},{"className":1310},[],[1312],{"type":52,"value":1313},"string | undefined",{"type":52,"value":1315}," at the call site. With ",{"type":47,"tag":70,"props":1317,"children":1319},{"className":1318},[],[1320],{"type":52,"value":133},{"type":52,"value":1322},", the hook accepts ",{"type":47,"tag":70,"props":1324,"children":1326},{"className":1325},[],[1327],{"type":52,"value":1328},"string | typeof skipToken",{"type":52,"value":1330},", so no cast is needed. If the argument type changes, TypeScript will catch the mismatch.",{"type":47,"tag":48,"props":1332,"children":1333},{},[1334,1336,1341,1343,1348],{"type":52,"value":1335},"We recommend ",{"type":47,"tag":70,"props":1337,"children":1339},{"className":1338},[],[1340],{"type":52,"value":133},{"type":52,"value":1342}," when we want the type system to enforce the condition. Use ",{"type":47,"tag":70,"props":1344,"children":1346},{"className":1345},[],[1347],{"type":52,"value":125},{"type":52,"value":1349}," when the condition involves logic that is not captured by the argument type, like disabling a query based on a feature flag.",{"type":47,"tag":151,"props":1351,"children":1353},{"id":1352},"optimistic-updates-with-onquerystarted",[1354,1355],{"type":52,"value":138},{"type":47,"tag":70,"props":1356,"children":1358},{"className":1357},[],[1359],{"type":52,"value":144},{"type":47,"tag":48,"props":1361,"children":1362},{},[1363,1365,1370,1371,1377],{"type":52,"value":1364},"For mutations where responsiveness matters, optimistic updates let the UI reflect the change before the server responds. RTK Query provides ",{"type":47,"tag":70,"props":1366,"children":1368},{"className":1367},[],[1369],{"type":52,"value":144},{"type":52,"value":127},{"type":47,"tag":70,"props":1372,"children":1374},{"className":1373},[],[1375],{"type":52,"value":1376},"updateQueryData",{"type":52,"value":1378}," for this.",{"type":47,"tag":48,"props":1380,"children":1381},{},[1382,1384,1389,1391,1397,1399,1405],{"type":52,"value":1383},"The pattern has three steps. First, we immediately patch the cache with the expected result using ",{"type":47,"tag":70,"props":1385,"children":1387},{"className":1386},[],[1388],{"type":52,"value":1376},{"type":52,"value":1390},". This is what the user sees right away. Second, we wait for the mutation to complete with ",{"type":47,"tag":70,"props":1392,"children":1394},{"className":1393},[],[1395],{"type":52,"value":1396},"await queryFulfilled",{"type":52,"value":1398},". Third, if the mutation fails, we call ",{"type":47,"tag":70,"props":1400,"children":1402},{"className":1401},[],[1403],{"type":52,"value":1404},"patchResult.undo()",{"type":52,"value":1406}," to roll back the cache to its state before the optimistic update.",{"type":47,"tag":48,"props":1408,"children":1409},{},[1410],{"type":52,"value":1411},"Here is a toggle for a todo's completed state:",{"type":47,"tag":190,"props":1413,"children":1416},{"className":1414,"code":1415,"language":195,"meta":7},[193],"type Todo = {\n  id: string\n  title: string\n  completed: boolean\n}\n\nconst todosApi = baseApi.injectEndpoints({\n  endpoints: (builder) => ({\n    getTodos: builder.query\u003CTodo[], void>({\n      query: () => ({ url: '/todos', method: 'GET' }),\n      providesTags: (result) =>\n        result\n          ? [\n              ...result.map(({ id }) => ({ type: 'Todos' as const, id })),\n              { type: 'Todos' as const, id: 'LIST' },\n            ]\n          : [{ type: 'Todos' as const, id: 'LIST' }],\n    }),\n\n    toggleTodo: builder.mutation\u003CTodo, { id: string; completed: boolean }>({\n      query: ({ id, completed }) => ({\n        url: `/todos/${id}`,\n        method: 'PATCH',\n        body: { completed },\n      }),\n      async onQueryStarted({ id, completed }, { dispatch, queryFulfilled }) {\n        const patchResult = dispatch(\n          todosApi.util.updateQueryData('getTodos', undefined, (draft) => {\n            const todo = draft.find((t) => t.id === id)\n            if (todo) {\n              todo.completed = completed\n            }\n          }),\n        )\n        try {\n          await queryFulfilled\n        } catch {\n          patchResult.undo()\n        }\n      },\n    }),\n  }),\n})\n\nexport const { useGetTodosQuery, useToggleTodoMutation } = todosApi\n",[1417],{"type":47,"tag":70,"props":1418,"children":1419},{"__ignoreMap":7},[1420],{"type":52,"value":1415},{"type":47,"tag":48,"props":1422,"children":1423},{},[1424,1426,1431,1433,1439,1441,1447,1448,1454,1456,1461,1463,1469,1471,1477,1479,1485,1487,1493,1495,1501,1503,1509,1511,1516,1518,1523],{"type":52,"value":1425},"Let's walk through the ",{"type":47,"tag":70,"props":1427,"children":1429},{"className":1428},[],[1430],{"type":52,"value":144},{"type":52,"value":1432}," callback. It receives the mutation argument (",{"type":47,"tag":70,"props":1434,"children":1436},{"className":1435},[],[1437],{"type":52,"value":1438},"{ id, completed }",{"type":52,"value":1440},") and an object with ",{"type":47,"tag":70,"props":1442,"children":1444},{"className":1443},[],[1445],{"type":52,"value":1446},"dispatch",{"type":52,"value":127},{"type":47,"tag":70,"props":1449,"children":1451},{"className":1450},[],[1452],{"type":52,"value":1453},"queryFulfilled",{"type":52,"value":1455},". We call ",{"type":47,"tag":70,"props":1457,"children":1459},{"className":1458},[],[1460],{"type":52,"value":1376},{"type":52,"value":1462}," with the name of the query we want to patch (",{"type":47,"tag":70,"props":1464,"children":1466},{"className":1465},[],[1467],{"type":52,"value":1468},"'getTodos'",{"type":52,"value":1470},"), its argument (",{"type":47,"tag":70,"props":1472,"children":1474},{"className":1473},[],[1475],{"type":52,"value":1476},"undefined",{"type":52,"value":1478},", since ",{"type":47,"tag":70,"props":1480,"children":1482},{"className":1481},[],[1483],{"type":52,"value":1484},"getTodos",{"type":52,"value":1486}," takes no argument), and a callback that receives an Immer draft of the cached data. Inside the callback, we find the todo with the matching ID and update its ",{"type":47,"tag":70,"props":1488,"children":1490},{"className":1489},[],[1491],{"type":52,"value":1492},"completed",{"type":52,"value":1494}," property directly. Immer handles the immutable update for us. The dispatch returns ",{"type":47,"tag":70,"props":1496,"children":1498},{"className":1497},[],[1499],{"type":52,"value":1500},"patchResult",{"type":52,"value":1502},", which holds an ",{"type":47,"tag":70,"props":1504,"children":1506},{"className":1505},[],[1507],{"type":52,"value":1508},"undo",{"type":52,"value":1510}," function. We then await ",{"type":47,"tag":70,"props":1512,"children":1514},{"className":1513},[],[1515],{"type":52,"value":1453},{"type":52,"value":1517},". If the server request succeeds, nothing more happens; the cache already shows the correct state. If it throws, we call ",{"type":47,"tag":70,"props":1519,"children":1521},{"className":1520},[],[1522],{"type":52,"value":1404},{"type":52,"value":1524}," and the cache rolls back to exactly what it was before the optimistic patch.",{"type":47,"tag":48,"props":1526,"children":1527},{},[1528],{"type":52,"value":1529},"Optimistic updates add complexity. We recommend them where the latency is noticeable to the user and the operation is unlikely to fail: toggling a checkbox, liking a post, reordering a list. For operations where failure is common or the server response includes data that differs from what the client sent, it is better to wait for the response instead.",{"type":47,"tag":1531,"props":1532,"children":1533},"hr",{},[],{"type":47,"tag":48,"props":1535,"children":1536},{},[1537,1539,1544,1546,1551,1553,1558],{"type":52,"value":1538},"The patterns here work together. ",{"type":47,"tag":70,"props":1540,"children":1542},{"className":1541},[],[1543],{"type":52,"value":75},{"type":52,"value":1545}," keeps feature endpoints isolated and co-located with the feature. A custom ",{"type":47,"tag":70,"props":1547,"children":1549},{"className":1548},[],[1550],{"type":52,"value":86},{"type":52,"value":1552}," centralizes auth and error handling so individual endpoints stay clean. Precise cache tags avoid over-fetching without losing consistency. ",{"type":47,"tag":70,"props":1554,"children":1556},{"className":1555},[],[1557],{"type":52,"value":109},{"type":52,"value":1559}," keeps components free of server-shape knowledge. None of these require upfront investment. We can add them one at a time as the app grows and the pain points become clear.",{"title":7,"searchDepth":1561,"depth":1561,"links":1562},2,[1563,1564,1566,1567,1568,1569,1570,1571,1573],{"id":153,"depth":1561,"text":156},{"id":208,"depth":1561,"text":1565},"Code splitting with injectEndpoints",{"id":388,"depth":1561,"text":391},{"id":509,"depth":1561,"text":512},{"id":744,"depth":1561,"text":747},{"id":953,"depth":1561,"text":956},{"id":1080,"depth":1561,"text":1083},{"id":1218,"depth":1561,"text":1572},"Selective fetching with skip and skipToken",{"id":1352,"depth":1561,"text":1574},"Optimistic updates with onQueryStarted","markdown","content:articles:react:how-to-use-rtk-query-the-right-scalable-way.md","content","articles/react/how-to-use-rtk-query-the-right-scalable-way.md","articles/react/how-to-use-rtk-query-the-right-scalable-way","md",1780066945413]