RTK Query (and a bit of Redux)
in the projects section of my website, you can find an overengineered project called visit this, please!
. since many companies require proficiency in Redux, and i had yet to try RTK Query, i chose to replace React Query with these technologies to make my website even more overengineered
i have some small projects with Redux, but they aren’t on my resume. the same goes for Redis, which i plan to add to the site later
Redux set up
first, i installed the necessary dependencies:
npm install @reduxjs/toolkit react-redux
RTK Query is included in @reduxjs/toolkit/query/react
then, i needed to set up them. since i don’t use a feature-based folder structure, all my slices reside in src/slices
you probably already know how to set up a Redux Toolkit project with TypeScript. if not, check out the Quick Start and TypeScript guides in the docs. here’s how my only slice looks:
import { User } from "@/user";
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
type SessionState = {
user: User | null;
};
export const sessionSlice = createSlice({
name: "session",
initialState: { user: null } as SessionState,
reducers: {
setSession: (state, action: PayloadAction<User | null>) => {
state.user = action.payload;
},
},
});
export const { setSession } = sessionSlice.actions;
export default sessionSlice.reducer;
RTK Query will handle the rest
now Redux Toolkit is working. adding the following to a component returns inital state, which is null
:
// ...
export default function Component() {
const user = useAppSelector((state) => state.session.user);
console.log(user);
// ...
}
what is RTK Query?
an example of RTK Query code will likely look familiar. here’s what i use in my Home
component:
// familiar properties, huh? except there's a named hook instead of useQuery
const { data, isLoading, error } = useGetPlacesQuery({
sort,
filter,
page,
search,
});
// only need the result to display loading and error states globally
const [, result] = useUpdateVoteByPlaceIdMutation();
/* prefetch on hover or focus:
<Card
onFocus={() =>
prefetchPlace({ id: place._id })
}
onMouseEnter={() =>
prefetchPlace({ id: place._id })
}
/>
*/
const prefetchPlace = usePrefetch("getPlaceById");
here’s an example of using a mutation (definitely not the best code, it was written many months ago):
function Votes({
placeId,
down,
up,
voted,
}: {
placeId: string;
down: number;
up: number;
voted: VoteType | undefined;
}) {
const [addVote, result] = useUpdateVoteByPlaceIdMutation();
const user = useAppSelector((state) => state.session.user);
return (
<>
<CheckboxField
type="radio"
onChange={() => addVote({ type: "UP", id: placeId })}
onClick={() => voted === "UP" && addVote({ type: null, id: placeId })}
disabled={!user || result.isLoading}
checked={voted === "UP"}
/>
<GeneralVoteCount>{up - down}</GeneralVoteCount>
<CheckboxField
type="radio"
onChange={() => addVote({ type: "DOWN", id: placeId })}
onClick={() => voted === "DOWN" && addVote({ type: null, id: placeId })}
disabled={!user || result.isLoading}
checked={voted === "DOWN"}
/>
</>
);
}
as you can see, differences from React Query are minimal:
- there’s no
queryClient
, but<yourApi>.util
offers some similar functionality, though you often don’t need it - instead of generic
useQuery
anduseMutation
, RTK Query generates named hooks from your endpoints. this is arguably more readable - mutations return a tuple with
[mutate, data]
. in my opinion, this is better, as renamingmutate
is easier
fun fact i noticed: both RTK Query and React Query don’t allow passing multiple arguments to a mutation. this is probably better for readability, but i have mixed feelings about this. sometimes just passing two arguments would be simpler
so, where do these hooks come from?
it’s similar to how you export actions from Redux slices
// for comparison, src/slices/sessionSlice.ts
export const { setSession } = sessionSlice.actions;
// src/services/api.ts
export const {
usePrefetch,
useGetPlacesQuery,
useAddPlaceMutation,
useEditPlaceMutation,
useGetPlaceByIdQuery,
useDeletePlaceByIdMutation,
// ...
} = api;
but what is api
? it’s basically a slice. at least it looks like one:
export const api = createApi({
reducerPath: "api",
baseQuery: fetchBaseQuery({
baseUrl: process.env.API_URL,
credentials: "include",
}),
tagTypes: ["Session", "Places", "PlacesSort", "PlacesFilter"],
endpoints: (builder) => ({
// ...
}),
});
important to notice:
fetchBaseQuery
is a fetch wrapper, think of it like Axios. it is used when an endpoint is triggered- tags are specified in advance
basically, these properties prepare our endpoints
the actual endpoints look like this:
export const api = createApi({
// ...
endpoints: (builder) => ({
getPlaces: builder.query<Place[], PlacesQuery>({
query: ({ sort, filter, page, search }) =>
`/places?sort=${sort}&filter=${filter}&page=${page}&search=${search}`,
providesTags: (_response, _error, { sort, filter }) => [
{ type: "Places", id: "LIST" },
{ type: "PlacesSort", id: sort },
{ type: "PlacesFilter", id: filter },
"Session",
],
}),
addPlace: builder.mutation<void, EditPlaceSchema>({
query: (data) => ({
url: "/places",
method: "POST",
body: data,
}),
invalidatesTags: [{ type: "Places", id: "LIST" }],
}),
editPlace: builder.mutation<void, EditPlaceMutation>({
query: (data) => ({
url: `/places/${placeId}`,
method: "PATCH",
body: data,
}),
invalidatesTags: (_response, _error, { placeId }) => [
{ type: "Places", id: "LIST" },
{ type: "Places", id: placeId },
],
}),
getPlaceById: builder.query<PlaceById, PlaceByIdQuery>({
query: ({ id, sort }) => `/places/${id}?sort=${sort}`,
providesTags: (_response, _error, { id }) => [
"Session",
{ type: "Places", id },
],
}),
// ...
}),
});
the code is slightly simplified. the builder
merges the baseQuery
with your endpoint definitions and generates the corresponding useXQuery
and useYMutation
hooks
unlike React Query, RTK Query automatically caches requests by endpoint and arguments, but you still need tags to invalidate that cache after a mutation
as you’ll notice, tags are limited to 2 formats:
// type
type Tag = string | { type: string; id?: string | number };
yep, no arbitrary properties. i had to add separate tags like { type: "PlacesSort", id: sort }
to invalidate places based on sort or filter parameters. in React Query, you could simply invalidate a key like { type: "Places", filter: "voted-by-me" }
(adapted to RTK Query style). i understand, this makes it easier to create type-safe tags, but i wouldn’t mind typing my custom optional keys
the real pain point is session management: when the server responds with a 401 Unauthorized
error, cached data is not cleared, which is expected. the problem is that there’s no way in RTK Query to update cached data based on a tag, you can only invalidate it. invalidation triggers a refetch, which result in another 401
error
the only workaround i found was to use a separate sessionSlice
(shown earlier) to manage the user state. this means manually dispatching the setSession
action alongside auth-related mutations:
function Example() {
const dispatch = useAppDispatch();
const { data, error, isLoading } = useGetSessionQuery();
const user = useAppSelector((state) => state.session.user);
// this confusing piece of code was created by trial and error
// it calls setSession when on initial load, when user their session is still valid
// we don't want to call this when a user has logged out but
// the cached data has not yet been updated
// the more readable alternative would be to use a custom baseQuery to
// automatically reset loggedOut on 401 Unauthorized error
// refactoring this is in my to do list
const [loggedOut, setLoggedOut] = useState(false);
if (data && !user && !loggedOut) {
dispatch(setSession(data));
}
const [logOut, logOutResult] = useLogOutMutation();
return (
<button
onClick={async () => {
try {
await logOut();
} catch {
/* empty */
}
dispatch(setSession(null));
setLoggedOut(true);
}}
>
log out
</button>
);
}
yes, it’s confusing. in React Query, a mutation or query is only unsuccessful if its function throws. my custom fetch wrapper was customized not to throw on 401
, allowing me to simply set the user to null in the onSuccess
handler:
const { isPending, mutate } = useMutation({
mutationFn: () => mutateApi("POST", "/log-out"),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["current-user"] });
setUser(null);
},
});
conclusion
RTK Query is rather good than bad, but proper auth handling and tags are too confusing. React Query is far more straightforward in these areas
at the same time, RTK Query’s structure makes it diffuclt to write messy code. when i started this project, i knew too little about clean architecture and often place query logic directly within components instead of abstracting it into custom hooks. RTK Query forces you to keep data-fetching logic separate (in React Query, the equivalent pattern would be to create your own custom hooks in a separate file) i’d argue that Redux also encourages a higher quality code
that said, i doubt i’d have learned what makes code clean by using a library that enforces it. on the contrary, i wouldn’t have felt the pain of writing spaghetti code, which is what motivated me to learn about maintainable architecture. the best way to learn is by making mistakes and fixing them, so don’t hesitate to start with a more flexible library like React Query
my conclusion: more flexible and straightforward libraries exists. it only seems to make sense to use RTK Query if you have junior developers who must be prevented from writing bad code on your team
nevertheless, RTK Query does have some other advantages over React Query:
- automatic caching based on query arguments (though tags are still needed for invalidation after mutations)
- typed tags
- less agressive defaults: no retries or refetching on window focus
mutate
functions are promises, allowing you toawait
andtry/catch
them instead of usingonSuccess
oronError
callbacks. this can prevent common hook-related issues and leads to more intuitive code (though it makes optimistic and pessimistic query updates more confusing)
if these points are compelling, try it out. you might form a different opinion