Install any skill in seconds. Free to start, no credit card required.
Get Started Free →RTK Query createApi best practices
.claude/skills/ledgerhq-rtk-query-api/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 26% | 0% |
| case-09 | ✗→✓ | ▲ Improved | 37% | 0% |
| case-07 | ✓→✓ | = Same ✓ | 28% | 0% |
| case-06 | ✓→✓ | = Same ✓ | 52% | 0% |
| case-02 | ✓→✓ | = Same ✓ | 53% | 0% |
createApi calls against the same backendtypescript// ✅ GOOD - state-manager/api.ts import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; import { EntityTags } from "./types"; export const myApi = createApi({ reducerPath: "myApi", baseQuery: fetchBaseQuery({ baseUrl: "/api" }), tagTypes: [EntityTags.Entity, EntityTags.Entities], endpoints: (build) => ({ getEntity: build.query<Entity, string>({ query: (id) => `entities/${id}`, providesTags: [EntityTags.Entity], }), }), }); export const { useGetEntityQuery } = myApi;
Define tags as enums in state-manager/types.ts:
typescriptexport enum EntityTags { Entity = "Entity", Entities = "Entities", }
In domain/api/, this is the default — not something you reach for once a second use case appears. Always split reaching the backend from what you ask it for:
| Half | Owner | Contains | | --- | --- | --- | | Reaching a backend | @shared/api-services — one dir per backend | Base URL, base query, retry, reducerPath, extraArgument contract | | What you ask it for | @domain/api-<name> | Endpoints, wire schemas, transforms, cache tags, hooks |
Doing it upfront costs nothing and means the second use case is a one-line addition rather than a migration. Two createApi calls against one backend would give you two store slices, two caches and two middlewares for one service.
The shared half declares an empty api. The use-case half adds to it with injectEndpoints for endpoints and enhanceEndpoints({ addTagTypes }) for tags. Both mutate and return the same api object, so one reducer, one middleware and one cache serve every use case.
There are no exceptions. If a backend's base query currently needs use-case knowledge — mock handlers keyed by endpoint URL, endpoint-name lookups, response types from its own wire schemas — that is a problem to fix in the base query, not a reason to keep a second createApi.
typescript// ✅ GOOD - the service api: base query + config. No endpoints, no tags. export const myServiceApi = createApi({ reducerPath: "myServiceApi", baseQuery: myServiceBaseQuery, tagTypes: [], endpoints: () => ({}), });
typescript// ✅ GOOD - a use case adds its own tags, then its endpoints export const FIRST_USE_CASE_TAGS = ["Entity"] as const; export const firstUseCaseApi = myServiceApi .enhanceEndpoints({ addTagTypes: FIRST_USE_CASE_TAGS }) .injectEndpoints({ endpoints: build => ({ getEntity: build.query<Entity, string>({ query: id => `entities/${id}`, providesTags: [...FIRST_USE_CASE_TAGS], }), }), }); export const { useGetEntityQuery } = firstUseCaseApi;
injectEndpoints does not accepttagTypes, which makes it tempting to declare every tag upfront in the shared file — don't. enhanceEndpoints({ addTagTypes }) widens the tag union in place, so a tag stays next to the endpoints that provide it and adding a use case never means editing a shared file.
with the endpoints — injectEndpoints cannot retype the original.
been evaluated as a value import; a type-only import will not trigger it. Never import an api from @shared/api-services in order to call endpoints on it.
on an injected reference (whose use case added some) will not accept an app's State. Type such helpers on the service api.
overrideExisting defaults to false — injecting an endpoint name that already exists issilently ignored unless you opt in.
build.query for GET requestsbuild.mutation for POST/PUT/DELETEbuild.query<ResponseType, ArgType>void for no arguments: build.query<Data[], void>types.tsprovidesTags on queries for cache invalidationinvalidatesTags on mutations to trigger refetchkeepUnusedDataFor for custom cache durationtypescriptendpoints: (build) => ({ getItems: build.query<Item[], void>({ query: () => "items", providesTags: [ItemTags.Items], keepUnusedDataFor: 60, // seconds }), addItem: build.mutation<Item, Partial<Item>>({ query: (body) => ({ url: "items", method: "POST", body }), invalidatesTags: [ItemTags.Items], }), }),
transformResponse to reshape API datatransformErrorResponse for custom error handlingtypescriptgetItems: build.query<Item[], void>({ query: () => "items", transformResponse: (response: ApiResponse) => response.data.items, }),
baseQuery or queryFn{ data } on success, { error } on failuretypescript// ✅ GOOD - errors are caught and returned queryFn: async (arg) => { try { const data = await fetchData(arg); return { data }; } catch (error) { return { error: { status: "CUSTOM_ERROR", data: error } }; } },
Register APIs in reducers/rtkQueryApi.ts, keyed by reducerPath. For a shared backend, register the service api — its endpoints arrive via the use-case packages the view-models import. The registry then reads as a list of the backends the app talks to:
typescriptconst APIs = { [myApi.reducerPath]: myApi, [myServiceApi.reducerPath]: myServiceApi, };
Two entries whose reducerPath resolves to the same string is a compile error (TS1117: An object literal cannot have multiple properties with the same name), even for computed properties — which is what catches an accidental double-registration of one backend.
Other measured skills in the registry, with their headline benchmark lift.