An endpoint is where the client meets the API. On the Sean Dinwiddie’s Webmastery team, endpoints are generated from the API’s own contract into one API root, reviewed like any other code, decoded before a view sees what they return, and tagged so the right views refetch.
This lesson carries the orders from The API: Haskell Servant and Nile into the client, beside the cart from From scenario to slice.
1. One API root per base URL
Every endpoint that talks to the same server joins one API root: one cache, one middleware, and tags that reach across features, so an order placed at checkout refreshes the order history on another screen. A second backend, such as a CMS that serves the shop’s pages or a search service, gets a root of its own.
The root starts with no endpoints, declares its tag types, and sends the signed-in user’s token on every request as the bearer token the API’s Auth combinator reads:
// app/api.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import type { RootState } from './store'
const baseQuery = fetchBaseQuery({
baseUrl: 'https://api.example.com/',
prepareHeaders: (headers, { getState }) => {
const token = (getState() as RootState).session.token
if (token) headers.set('Authorization', `Bearer ${token}`)
return headers
},
})
export const api = createApi({
reducerPath: 'api',
baseQuery,
tagTypes: ['Order'],
endpoints: () => ({}),
})
The store registers the root’s reducer and its middleware once; without the middleware, nothing subscribes, refetches or invalidates, and in development and tests the first request throws an error that names the missing middleware. The lecture Redux Toolkit and RTK Query Best Practices shows what a second root for the same backend costs.
2. Generate the endpoints, then review them
The API writes its contract to an OpenAPI document from the Servant type on every build. RTK Query’s code generator reads that document and injects typed endpoints into the root:
// openapi-config.ts
import type { ConfigFile } from '@rtk-query/codegen-openapi'
const config = {
schemaFile: './openapi.json',
apiFile: './src/app/api.ts',
apiImport: 'api',
outputFile: './src/features/orders/ordersApi.generated.ts',
exportName: 'generatedOrdersApi',
hooks: true,
filterEndpoints: ['listOrders', 'placeOrder'],
} satisfies ConfigFile
export default config
A config written in TypeScript, as this one is, needs esbuild-runner or ts-node installed beside the generator. Three things shape what it generates:
- Each endpoint is named for its route’s
operationId, which the API sets as it builds the document; without one, the generator would build a name from the method and the path. filterEndpointskeeps only the routes this feature owns.- When the generator guesses wrong, such as a search sent as a POST, which it would make a mutation, an entry in
endpointOverridescorrects it.
The generated file is read in review like any other source and never edited by hand: the config changes, the generator runs again, and the diff is reviewed. The lecture Redux Toolkit and RTK Query Best Practices lists what that review checks.
3. Decode at the boundary
The generated types say what the API promised; they don’t check what arrives. A server deployed ahead of its client, or a gateway that answers 200 with JSON of its own, can answer with something else (a page that isn’t JSON at all never gets that far: fetchBaseQuery reports it as a PARSING_ERROR). So every response is unknown until a decoder accepts it, written as a pure function that returns the lectures’ small Either, kept in shared/either.ts:
// features/orders/decodeOrders.ts
import { left, right, type Either } from '../../shared/either'
import type { Order } from './ordersApi.generated'
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null
const isLine = (value: unknown) =>
isRecord(value) &&
typeof value.productId === 'string' &&
typeof value.name === 'string' &&
Number.isInteger(value.priceCents) &&
Number.isInteger(value.quantity)
const isOrder = (value: unknown): value is Order =>
isRecord(value) &&
typeof value.id === 'string' &&
Number.isInteger(value.totalCents) &&
Array.isArray(value.lines) &&
value.lines.every(isLine)
export const decodeOrder = (raw: unknown): Either<string, Order> =>
isOrder(raw) ? right(raw) : left('Expected an order')
export const decodeOrders = (raw: unknown): Either<string, Order[]> =>
Array.isArray(raw) && raw.every(isOrder) ? right(raw) : left('Expected a list of orders')
value is Order is a promise the compiler takes on trust, so when the generated Order changes, its decoder changes in the same review.
The root runs the decoder an endpoint names before anything is cached, and a payload that fails becomes an ordinary error the view can show, never a crash:
// app/api.ts, continued
import type { BaseQueryFn, FetchArgs, FetchBaseQueryError, FetchBaseQueryMeta } from '@reduxjs/toolkit/query/react'
import type { Either } from '../shared/either'
type Decode = (raw: unknown) => Either<string, unknown>
const decodingBaseQuery: BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError, { decode?: Decode }, FetchBaseQueryMeta> =
async (args, queryApi, extraOptions) => {
const result = await baseQuery(args, queryApi, {})
const decode = extraOptions?.decode
if (result.error || !decode) return result
const decoded = decode(result.data)
return decoded._tag === 'Right'
? { data: decoded.value, meta: result.meta }
: { error: { status: 'CUSTOM_ERROR', error: decoded.error, data: result.data }, meta: result.meta }
}
In the finished file, decodingBaseQuery sits between baseQuery and createApi, which takes it in place of baseQuery.
CUSTOM_ERROR, with the raw payload beside it, for the view to show.An endpoint that names no decoder passes through unchecked, so the review checks that every endpoint names one; the cart under “Where it bends”, below, leaves its decoder out only to keep the patch in view.
RTK Query can also run the same check itself, as an endpoint’s responseSchema, for validators written to the Standard Schema interface. The lecture Practical Applications of Functional Programming holds to the same rule: an endpoint checks the response before any component receives it.
4. Tags say what to refetch
A query provides tags for what it holds, and a mutation invalidates the tags its change makes stale. The generated endpoints take their decoders and tags in one reviewed file beside the generated one:
// features/orders/ordersApi.ts
import { generatedOrdersApi } from './ordersApi.generated'
import { decodeOrder, decodeOrders } from './decodeOrders'
export const ordersApi = generatedOrdersApi.enhanceEndpoints({
endpoints: {
listOrders: {
extraOptions: { decode: decodeOrders },
providesTags: (orders = []) => [
{ type: 'Order', id: 'LIST' },
...orders.map(({ id }) => ({ type: 'Order' as const, id })),
],
},
placeOrder: {
extraOptions: { decode: decodeOrder },
invalidatesTags: (order) => (order ? [{ type: 'Order', id: 'LIST' }] : []),
},
},
})
export const { useListOrdersQuery, usePlaceOrderMutation } = ordersApi
When the API accepts an order, the list is stale, and every view showing it refetches. When the API refuses, the callback returns no tags: RTK Query invalidates on a handled error too, and a refused order changes nothing. A list with no view subscribed is dropped instead, and fetched fresh when a view next asks for it.
{ type: 'Order', id: 'LIST' }, and listOrders, which provides it, refetches for every view that shows it (the violet row). A refused order gets no tags from the callback, so the history stays as it was.The tags are written here, where they are reviewed, because tags generated from the document are only as fine as the document’s own grouping. The lecture Redux Toolkit and RTK Query Best Practices walks through invalidation.
5. The scenario at the endpoint
An endpoint is tested against a controlled boundary: a real store with the root’s reducer and middleware, and a stand-in for the server behind fetch.
The view builds the new order from the slice’s lines, product and quantity only; the price the customer saw is for display, and the API prices the order in its own transaction. The test carries the same scenario name as the API’s, and because the slice shares its store, it checks the slice’s Then too:
// features/orders/ordersApi.test.ts
import { configureStore } from '@reduxjs/toolkit'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from '../../app/api'
import { cartSlice, itemAdded, selectLines } from '../cart/cartSlice'
import { ordersApi } from './ordersApi'
const tenantId = '018ade1a-7830-7981-b23f-f3a7f7b8f09f'
const placed = {
id: 'order-1',
lines: [{ productId: 'widget-a', name: 'Widget A', priceCents: 2500, quantity: 1 }],
totalCents: 2500,
}
// The server, controlled: it records what is posted and lists what it holds.
const fakeServer = () => {
const orders: (typeof placed)[] = []
return async (request: Request) => {
if (request.method === 'POST') {
orders.push(placed)
return Response.json(placed, { status: 201 })
}
return Response.json(orders)
}
}
const makeStore = () =>
configureStore({
reducer: {
[cartSlice.reducerPath]: cartSlice.reducer,
[api.reducerPath]: api.reducer,
session: () => ({ token: 'test-token' }),
},
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(api.middleware),
})
afterEach(() => vi.unstubAllGlobals())
describe('Feature: Checkout', () => {
it('Scenario: Placing the order', async () => {
const server = vi.fn(fakeServer())
vi.stubGlobal('fetch', server)
const store = makeStore()
// Given Ada's cart holds "Widget A" at $25, and her order history is on screen
store.dispatch(itemAdded({ productId: 'widget-a', name: 'Widget A', priceCents: 2500 }))
const history = store.dispatch(ordersApi.endpoints.listOrders.initiate({ tenantId }))
await history
// When she places the order
await store.dispatch(
ordersApi.endpoints.placeOrder.initiate({
tenantId,
newOrder: { lines: [{ productId: 'widget-a', quantity: 1 }] },
}),
)
// Then the order is recorded: her history refetches and shows it; and her cart is empty
await vi.waitFor(() =>
expect(ordersApi.endpoints.listOrders.select({ tenantId })(store.getState()).data).toEqual([placed]),
)
expect(selectLines(store.getState())).toEqual([])
const [[listed], [posted]] = server.mock.calls
expect(listed.headers.get('Authorization')).toBe('Bearer test-token')
expect(await posted.json()).toEqual({ lines: [{ productId: 'widget-a', quantity: 1 }] })
history.unsubscribe()
})
})
The same file carries it('Scenario: Listing my orders'), two orders decoded and cached with her token on the request, and checks the failure paths: a JSON response that isn’t a list of orders ends as a CUSTOM_ERROR with the raw payload beside it, and a refused order leaves the history as it was. The lecture Redux Toolkit and RTK Query Best Practices lists what each layer’s tests cover, tag invalidation and optimistic rollback included.
The endpoint sits between the slice and the API, and each layer proves its own part of one scenario under the same name: the Hspec test that the API records the order, and this test that the history shows it and that the slice, hearing the order accepted, empties the cart.
The next lesson, The view stays minimal, builds the view that places the order: it reads the cart through selectors, calls usePlaceOrderMutation with each line’s product and quantity, and shows what the endpoint returns.