rtk-query-split
This SPA splits RTK Query into two folders per backend API. The split breaks a circular dep with src/shells/store.ts, matches RTK Query's official "empty api + inject" pattern, and keeps the store lean.
Layout
Infra — src/services/<name>Client.ts:
- Declares the empty base client (base URL, cache config, tag types, custom signed-fetch baseQuery when needed).
- No endpoints.
- Imported by
src/shells/store.tsto register reducers and middleware. - Existing bases:
cmsClient,placesClient,socialClient,cast2Client,storageClient,subgraphClient,marketplaceClient,referralClient(the profile tabs injectprofile.*.client.tsendpoints into the last two). - Two legacy bases live next to their endpoints (still load via the shell):
eventsClient(features/events/events.client.ts) andadminClient(features/events/events.admin.client.ts).
Business logic — src/features/<api>/<api>.client.ts:
- And siblings:
<api>.admin.client.ts,<api>.search.client.ts, etc. - Calls
<base>.injectEndpoints({ endpoints: builder => ({ ... }) }). - Example:
features/cms/cms.client.tsinjectsgetBlogPosts,getBlogPost,getBlogCategories, … intocmsClient.features/cms/cms.search.client.tsinjects the search endpoints into the samecmsClient.
Why the split
- Breaks a circular dep.
store.tsimports the base client. Endpoints infeatures/cms/cms.client.tsimportstorefor one cache-read optimization (getPostFromStore). If the base client lived next to the endpoints, that would cycle. The split keeps the hub (services/) free of app-layer imports. - Matches RTK Query's recommended pattern (code splitting). The "empty api + inject" idiom scales cleanly with each new dapp.
- Keeps the store lean.
store.tsdoesn't need to import feature endpoint definitions just to register reducers/middleware.
Steps — adding endpoints to an existing base
- Create
src/features/<domain>/<domain>.client.ts(or sibling like<domain>.admin.client.ts,<domain>.search.client.ts). - Call
<base>.injectEndpoints({ endpoints: builder => ({ ... }) }). - Re-export the generated hooks from
src/features/<domain>/index.ts— barrel MUST export ALL public hooks (CLAUDE.md Pre-PR rule 7).
Steps — adding a new base client
Only when the new domain genuinely doesn't fit any existing base (cmsClient, placesClient, socialClient, cast2Client, storageClient, subgraphClient, eventsClient, adminClient).
- Create
src/services/<name>Client.tswithcreateApi({ reducerPath: '<name>Client', baseQuery: ..., endpoints: () => ({}) }). - Register reducer + middleware in
src/shells/store.ts. - Add a test asserting the store builds with the expected
reducerPathkey (CLAUDE.md Pre-PR rule 6).
Rule 17 — No direct store imports in endpoint files
Endpoint files (features/<domain>/<domain>.client.ts) must NOT import { store } from '.../shells/store' for dispatching inside transformResponse or queryFn. That creates a circular dep with store.ts and breaks tree-shaking guarantees.
Use RTK Query's onQueryStarted lifecycle instead:
getBlogPosts: builder.query({
query: args => `posts?${args}`,
async onQueryStarted(arg, { dispatch, queryFulfilled }) {
try {
const { data } = await queryFulfilled
dispatch(postsUpserted(data.items))
} catch {
/* hook surfaces error */
}
}
})
A single legitimate read of store.getState() for cache-check optimizations is tolerated (see features/cms/cms.client.ts:getPostFromStore) as long as the file does NOT dispatch from within RTK Query callbacks.
Rule 18 — No internal cache state access
NEVER reach into state.cmsClient.queries or similar internals via as any casts. The shape is undocumented and changes between @reduxjs/toolkit minor versions.
Read cached data through one of:
- Normalized entity-adapter selectors (preferred —
selectBlogPostById(state, id)). cmsClient.endpoints.getBlogPost.select(args)(state).- Generated hooks with
selectFromResultinside components.
If the data isn't reachable through any of the above, add an onQueryStarted that upserts into an entity adapter — then select from there.
Pitfalls
- Forgetting to re-export hooks from the feature's
index.ts→ consumers do deep imports → barrel contract breaks (Pre-PR rule 7). - Throwing at module top-level for env-var validation (
if (!CMS_BASE_URL) throw) — crashes the entire lazy chunk load. Use a lazy getter that throws on invocation instead (CLAUDE.md Pre-PR rule 16). - Mutating data returned by
queryFn/transformResponse/updateQueryData— RTK Query expects immutability. Build enrichment in aMap, then map over an immutable copy (Pre-PR rule 22). - Re-creating a base client per route — they're singletons. Inject endpoints, don't create duplicates.