As I’ve been changing a website to conform to some of the changed in the Redux Style Guide, I’ve started learning about RTK Query. In the past, I’ve used React-Query, which seems fairly similar in that it encapsulates most of the boilerplate logic to query an API. Unlike React-Query, RTK Query creates hooks for every API call, which aids in type casting when using TypeScript.
What are the effects of introducing RTK Query? In the past, most of my Redux Store was used to maintain a partial copy of a database that the user was working with. RTK Query does most of the same thing, but within its own little slice in the redux store. You don’t access the data directly, but you can monitor it’s flow through Pending/Fulfilled/Rejected actions in order to update local stores with the data being transferred.
In addition to using Redux DevTools to monitor the global store and actions, it has another area to monitor RTK Queries.

The use of selectors in front end components is greatly reduced since you’re no longer selecting load state, errors, and data from the slice – but getting the data from a hook
const {
data,
isLoading,
isSuccess,
isError,
error
} = useGetTypesByParentIdQuery({ parentId });
At this point, I no longer needed to store most of my data in the store – except for data that I needed to manipulate. Specifically, with Drag & Drop, I needed to be able to be aware of the order of ID’s that could be moved around for a given parent. However, I no longer needed to store the items themselves since the rest of the information was in RTK. Here is what I’ve got now:
export interface TypesState {
savingIds: number[],
allIds: number[],
childIds: ById<(number | null)[]>,
movableIds: ById<(number | null)[]>,
movableSnapshotIds: ById<(number | null)[]>
}
Everything has been reduced to numbers. ChildId’s lets me look up all children for a given parent. ie ids = childIds[parentId]. The movable keys are for when Drag & Drop starts to drag items over the list. The snapshot allows me to save the most recent drop, or revert back to it before saving.
Although Redux RTK Query allows you to access the loading state of an endpoint, it’s not helpful in showing that the data is being mutated from a separate api call. To work around this, I setup “extra reducers” to listen for the action to reposition the data, and update the state with the id’s being requested. Once it’s fulfilled or failed, those saving id’s are removed.
There are a few things I don’t like about it. The guide states that createApi should contain all API endpoints for a single server. You should not create multiple API slices for the same server. For a site with many endpoints, this gets a little out of hand. For example, I have one folder with a few endpoints for CRUD operations and a few read operations for a given type of data:
| Endpoint | AP |
|---|---|
| /types/add | createType |
| /types/delete | deleteType |
| /types/byi | getTypeByI |
| /types/features | getTypeFeaturesById |
| /types/search | getTypesByParentId |
| /types/modify | modifyType |
| /types/reposition | modifyTypePositions |
We’ve got seven endpoints for one group of data. Now when you add more types of data, it gets a bit out of hand. What I ended up doing is modularizing the building of my endpoints so that I can separate them by data type. First was the building of the object itself…
export const api = createApi({
reducerPath,
baseQuery: fetchBaseQuery({
baseUrl: 'https://mydomain/api'
}),
tagTypes,
endpoints: builder => ({
...dataType1Endpoint(builder),
...dataType2Endpoint(builder),
...dataType3Endpoint(builder),
...
})
});
The builder for a given endpoint is also modularized
export default (
builder: Builder
) => ({
...modifyTypePositions(builder),
...getTypesByParentId(builder),
...getTypeFeaturesById(builder),
...getTypeByIds(builder),
...getTypeById(builder),
...modifyType(builder),
...createType(builder),
...deleteTypes(builder)
});
And finally, the query is built
export default (
builder: Builder
) => ({
getTypeById: builder.query<TypeData, number>({
query: id => ({
url: '/types/byid',
method: 'POST',
body: { ids: [id] }
}),
transformResponse: ({ rows }: MultiItemResponse<TypeData>, _meta, _arg) =>
rows[0] ?? undefined,
providesTags: (results, _error, _ids) =>
results ? [{ type, id: results.id }] : []
})
});
With all of that, I found that TypeScript slowed me down as I don’t have a simple type to use to represent the builder. I went ahead and worked out what TypeScript wanted.
export type Builder = EndpointBuilder<
BaseQueryFn<
string | FetchArgs,
unknown,
FetchBaseQueryError,
{},
FetchBaseQueryMeta
>,
'dataTypeTag1' | 'dataTypeTag2' | 'dataTypeTag3',
'api'
>
It was required that the reducer path and cache tags for each data type were included in the EndpointBuilder in order to get TypeScript to play nice.
Extra Reducers
Each endpoint has three actions – pending, fulfilled, and rejected. I’m more familiar with the trigger, request, success, failure, fulfill life cycle from redux saga actions. With RTK Query, you don’t have actions. Instead, you have matchers at the end of your endpoints
In my case, I needed to capture which types were being moved around, and store their id’s in order to show a saving indicator.
const {
matchFulfilled,
matchPending,
matchRejected
} = api.endpoints.modifyTypePositions;
export default (
builder: ActionReducerMapBuilder<TypesState>
) => {
builder
.addMatcher(matchPending, (state, action) => {
const orders = action.meta.arg.originalArgs;
const ids = orders.map(({ id }) => id);
state.savingIds.push(...ids);
})
.addMatcher(matchFulfilled, (state, action) => {
const items = action.payload;
items.forEach(addOrUpdateWith(state));
const orders = action.meta.arg.originalArgs;
const ids = orders.map(({ id }) => id);
state.savingIds = state.savingIds
.filter(id => !ids.includes(id));
})
.addMatcher(matchRejected, (state, action) => {
const orders = action.meta.arg.originalArgs;
const ids = orders.map(({ id }) => id);
state.savingIds = state.savingIds
.filter(id => !ids.includes(id));
})
}
With all of the fetch actions moved into the api slice, some of my slices no longer had reducers – just extra reducers.
Sagas
Are saga’s still used? Yes, but very limited. Redux RTK Query folks seem to recommend sagas as a last approach as they favor RTK Query, Async Thunks, and RTK Listeners. Here is what they have to say about Sagas.
We specifically recommend against sagas or observables for most reactive logic for multiple reasons:
Sagas: require understanding generator function syntax as well as the saga effects behaviors; add multiple levels of indirection due to needing extra actions dispatched; have poor TypeScript support; and the power and complexity is simply not needed for most Redux use cases.
Observables: require understanding the RxJS API and mental model; can be difficult to debug; can add significant bundle size
Generator functions are not an issue for me. As far as levels of indirection, that’s pretty much the reason I need them. If someone drags and drops an item, I need to ensure the API is called to make the change. I’d have to agree about the TypeScript support. I have to keep casing everything in order for TypeScript to play nice with yield calls. As far as Observables, I had my share of them with Angular, and they tended to be messy with odd effects sometimes making multiple calls to get the same data, and had plenty of boilerplate code.
One of the remaining sagas initiates an endpoint to change the order of items after they have been dropped. Here is the changes saga:
const { selectMoved } = slice.selectors;
const { drop } = dnd.actions;
const { modifyTypePositions } = api.endpoints;
type Moved = ReturnType<typeof selectMoved>;
export default takeEvery(drop.type, worker);
function* worker() {
const moved: Moved = yield select(selectMoved);
if (moved.length === 0) return;
yield put(modifyTypePositions.initiate(moved));
}
I haven’t looked into the proper way to setup the async thunk just yet, but it’s on the horizon. Afterwards, all remaining sagas may be gutted from the code base.
