The Redux team is working hard to create tools that are helpful in many real-life situations. For example, Redux Toolkit is a big step in simplifying how we write applications with Redux. Another helpful tool is the Redux Toolkit Query. Its primary sources of inspiration are the Apollo Client and React Query.
The code we usually write for fetching data is often very repetitive. RTK Query aims to do a lot of the work and make our code more consistent and concise. It comes with loading state handling and even cache management. On top of that, RTK Query generates React hooks ready to use. It offers excellent support for TypeScript because it is written in it.
In this article, we go through all the basic features of RTK Query while using TypeScript.
Here we use an official Redux Toolkit template with TypeScript. To get it, run npx create-react-app my-app --template redux-typescript.
Defining the basics of our API
To start using the RTK Query, we need to use the createApi function.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; import Photo from './photo'; export const api = createApi({ reducerPath: 'api', baseQuery: fetchBaseQuery({ baseUrl: process.env.REACT_APP_API_URL, }), endpoints: (builder) => ({ getPhotos: builder.query<Photo[], void>({ query: () => 'photos', }), getPhotoById: builder.query<Photo, number>({ query: (photoId: number) => `photos/${photoId}`, }), updatePhoto: builder.mutation<Photo, { id: number; data: Partial<Photo> }>({ query: ({ id, data }) => ({ url: `photos/${id}`, method: 'PATCH', body: data, }), }), }), }); export const { useGetPhotosQuery, useGetPhotoByIdQuery, useUpdatePhotoMutation, } = api; |
A few important things are happening above. First, we need to define the reducerPath property to tell the Redux Toolkit where we want to keep all of the data from the API in our store.
We also need to tell the RTK how to make our API requests by providing the baseQuery parameter. A common way is using the fetchBaseQuery function, a wrapper around the native Fetch API. Even though the above is the solution suggested by the official documentation, RTK aims not to enforce it. So, for example, we can write our base query function that uses axios.
In the property called endpoints, we specify a set of operations we want to perform with the API. We do that by using the builder object. When we aim to retrieve the data, we should use the builder.query function. When we want to alter the data on the server, we need to call the builder.mutation method.
Both builder.query and builder.mutation uses generic types. For example, in getPhotoById: builder.query<Photo, number> the Photo is the type of data in the response. When getting the photo, the number is the type of argument we want the user to pass. Since the getPhotos query does not require any arguments, we must explicitly pass void.
Defining the endpoints causes the React Toolkit to generate a set of hooks to interact with our API. We need to use TypeScript in version 4.1 or greater for the hooks to work correctly.
In our code snippet, we use the REACT_APP_API_URL environment variable. Therefore, we need to add it to our .env file for the url to be available.
.env
1 |
REACT_APP_API_URL=https://jsonplaceholder.typicode.com |
Attaching the RTK Query to the store
The createApi function creates a reducer and a middleware we need to attach to our existing Redux store.
If you want to know more about Redux Middleware, check out Redux middleware and how to use it with WebSockets
1 2 3 4 5 6 7 8 9 10 11 12 13 |
import { configureStore, ThunkAction, Action } from '@reduxjs/toolkit'; import { api } from './api/api'; import { setupListeners } from '@reduxjs/toolkit/query'; export const store = configureStore({ reducer: { [api.reducerPath]: api.reducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(api.middleware), }); setupListeners(store.dispatch); |
Above, the setupListeners is a thing worth noting. We can set the refetchOnFocus or the refetchOnReconnect flags to true when using a query. They tell RTK Query to refetch the data when the application window regains focus, or the network connection is reestablished. For it to work, we need to listen to events such as:
When we look under the hood, we can see that the setupListeners function attaches callbacks to the above events. So as long as you don’t use the mentioned flags, you don’t need to use setupListeners.
Querying the data
The essential auto-generated hook related to queries is the useQuery. Using it automatically fetches the data.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
import React, { FunctionComponent } from 'react'; import { useGetPhotoByIdQuery } from './api/api'; import Loader from './Loader'; interface Props { photoId: number; } const PhotoCard: FunctionComponent<Props> = ({ photoId }) => { const { data, isLoading, isError } = useGetPhotoByIdQuery(photoId); if (isLoading) { return <Loader />; } if (isError || !data) { return <div>Something went wrong</div>; } return ( <div> <p>{data.title}</p> <img src={data.url} alt={data.title} /> </div> ); }; export default PhotoCard; |
The cache behavior
It is essential to understand that if we render the PhotoCard multiple times with the same photoId, the useGetPhotoByIdQuery hook returns the result from the cache. The above happens as long as 60 seconds didn’t yet pass until the last component stopped using the useGetPhotoByIdQuery hook with a given id.
60 seconds can be changed to another value using the keepUnusedDataFor property.
We can alter this behavior in a few ways. For example, we can use the pollingInterval property to allow the query to refresh automatically.
1 2 3 |
const { data, isLoading, isError } = useGetPhotoByIdQuery(photoId, { pollingInterval: 60_000 // 1 minute }); |
Above I’m using a numeric separator to make the number more readable.
We can also use the refetchOnMountOrArgChange flag to skip the cached result.
1 2 3 |
useGetPhotoByIdQuery(photoId, { refetchOnMountOrArgChange: true }); |
Instead of true we can also pass a number of seconds. In this case RTK refetches if enough time has passed.
RTK also gives us a way to manually request the latest data with the refetch function.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
import React, { FunctionComponent } from 'react'; import { useGetPhotoByIdQuery } from './api/api'; import Loader from './Loader'; interface Props { photoId: number; } const PhotoCard: FunctionComponent<Props> = ({ photoId }) => { const { data, isLoading, isError, refetch } = useGetPhotoByIdQuery(photoId); if (isLoading) { return <Loader />; } if (isError || !data) { return <div>Something went wrong</div>; } return ( <div> <button type="button" onClick={refetch}> Refetch </button> <p>{data.title}</p> <img src={data.url} alt={data.title} /> </div> ); }; export default PhotoCard; |
We can also use the refetchOnFocus and refetchOnReconnect flags as mentioned before in this article.
Updating the data with mutations
To update the data, we need to use mutations. To do that, we can use the most significant hook used for mutation, the useMutation hook.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
import React, { ChangeEvent, FunctionComponent, useState } from 'react'; import { useUpdatePhotoMutation } from './api/api'; interface Props { photoId: number; } const PhotoTitleInput: FunctionComponent<Props> = ({ photoId }) => { const [newTitle, setNewTitle] = useState(''); const [updatePhoto] = useUpdatePhotoMutation(); const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => { setNewTitle(event.target.value); }; const handleSubmit = () => { updatePhoto({ id: photoId, data: { title: newTitle } }) }; return ( <div> <input name="title" onChange={handleInputChange} /> <button type="button" onClick={handleSubmit}> Save </button> </div> ); }; export default PhotoTitleInput; |
The crucial thing is that mutating a post with a given id should modify the cache. One way to achieve that is with tags.
Automated cache invalidating with tags
RTK query creates a tag system to automatically refetch the data affected by mutations. Queries can provide tags, while mutations can invalidate them.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; import Photo from './photo'; export const api = createApi({ reducerPath: 'api', tagTypes: ['Photos'], baseQuery: fetchBaseQuery({ baseUrl: process.env.REACT_APP_API_URL, }), endpoints: (builder) => ({ getPhotos: builder.query<Photo[], void>({ query: () => 'photos', providesTags: [{ type: 'Photos', id: 'LIST' }], }), getPhotoById: builder.query<Photo, number>({ query: (photoId: number) => `photos/${photoId}`, providesTags: (result, error, id) => [{ type: 'Photos', id }], }), updatePhoto: builder.mutation<Photo, { id: number; data: Partial<Photo> }>({ query: ({ id, data }) => ({ url: `photos/${id}`, method: 'PATCH', body: data, }), invalidatesTags: (result) => result ? [ { type: 'Photos', id: result.id }, { type: 'Photos', id: 'LIST' }, ] : [], }), }), }); |
In our example above, we can see that the getPhotos provides just one tag: { type: 'Photos', id: 'LIST' }.
The getPhotoById query provides one tag per photo. For example, using useGetPhotoByIdQuery(1) causes the { type: 'Photos', id: 1 } tag to be created.
The updatePhoto mutation would invalidate two tags if the update were successful.
1 2 3 4 5 6 |
updatePhoto({ id: 1, data: { title: 'New photo title', }, }); |
Using the above mutation invalidates the following tags:
- { type: 'Photos', id: 1 }
- { type: 'Photos', id: 'LIST' }
Because of that, running the above mutation causes three API requests:
- PATCH /photos/1,
- GET /photos/1,
- GET /photos.
Thanks to doing the above, the cache is refreshed for the getPhotos and the getPhotoById query.
Manual cache invalidating
Making additional API requests every time we change a single photo is not optimal. In RESTful APIs, PATCH and PUT requests often return the modified entity. We can use that to perform manual cache invalidation. To do that, we need to define an onQueryStarted function.
One way of doing the above is to perform an optimistic update. We assume that our API request will probably be successful, and we want to make the changes immediately. However, if our API request fails for some reason, we undo our changes.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; import Photo from './photo'; export const api = createApi({ // ... endpoints: (builder) => ({ // ... updatePhoto: builder.mutation<Photo, { id: number; data: Partial<Photo> }>({ // ... async onQueryStarted({ id, data }, { dispatch, queryFulfilled }) { const getPhotoByIdPatch = dispatch( api.util.updateQueryData('getPhotoById', id, (currentPhotoValue) => { Object.assign(currentPhotoValue, data); }), ); const getAllPhotosPatch = dispatch( api.util.updateQueryData('getPhotos', undefined, (photosList) => { const photoIndex = photosList.findIndex((photo) => photo.id === id); if (photoIndex > -1) { const currentPhotoValue = photosList[photoIndex]; Object.assign(photosList[photoIndex], { ...currentPhotoValue, ...data, }); } }), ); try { await queryFulfilled; } catch { getPhotoByIdPatch.undo(); getAllPhotosPatch.undo(); } }, }), }), }); |
The second approach we can implement is the pessimistic update. Here, we wait for the request to be completed before modifying the cache.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; import Photo from './photo'; export const api = createApi({ // ... endpoints: (builder) => ({ // ... updatePhoto: builder.mutation<Photo, { id: number; data: Partial<Photo> }>({ // ... async onQueryStarted({ id }, { dispatch, queryFulfilled }) { try { const { data: updatedPost } = await queryFulfilled; dispatch( api.util.updateQueryData( 'getPhotoById', id, (currentPhotoValue) => { Object.assign(currentPhotoValue, updatedPost); }, ), ); dispatch( api.util.updateQueryData('getPhotos', undefined, (photosList) => { const photoIndex = photosList.findIndex( (photo) => photo.id === id, ); if (photoIndex > -1) { const currentPhotoValue = photosList[photoIndex]; Object.assign(photosList[photoIndex], { ...currentPhotoValue, ...updatedPost, }); } }), ); } catch {} }, }), }), }); |
Splitting the code
When using React Toolkit Query, we are expected to use the createApi function just once. However, when our application uses a lot of different endpoints, defining them in one place can lead to a messy codebase. Fortunately, with RTK, we can split our endpoints into multiple additional files.
1 2 3 4 5 6 7 8 9 10 |
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; export const api = createApi({ reducerPath: 'api', tagTypes: ['Photos'], baseQuery: fetchBaseQuery({ baseUrl: process.env.REACT_APP_API_URL, }), endpoints: () => ({}), }); |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
import { api } from './api'; import Photo from './photo'; const photosApi = api.injectEndpoints({ endpoints: (builder) => ({ getPhotos: builder.query<Photo[], void>({ query: () => 'photos', providesTags: [{ type: 'Photos', id: 'LIST' }], }), getPhotoById: builder.query<Photo, number>({ query: (photoId: number) => `photos/${photoId}`, providesTags: (result, error, id) => [{ type: 'Photos', id }], }), updatePhoto: builder.mutation<Photo, { id: number; data: Partial<Photo> }>({ query: ({ id, data }) => ({ url: `photos/${id}`, method: 'PATCH', body: data, }), invalidatesTags: (result) => result ? [ { type: 'Photos', id: result.id }, { type: 'Photos', id: 'LIST' }, ] : [], }), }), }); export const { useGetPhotosQuery, useGetPhotoByIdQuery, useUpdatePhotoMutation, } = photosApi; |
Please notice that we’re still defining the tagTypes array in the main API definition. An alternative to doing that is to use the enhanceEndpoints function.
1 2 3 4 5 6 7 8 |
import { api } from './api'; import Photo from './photo'; const apiWithTags = api.enhanceEndpoints({ addTagTypes: ['Photos'] }); const photosApi = apiWithTags.injectEndpoints({ // ... }); |
Summary
We’ve gone through all of the features we need to start using React Toolkit Query in this article. This included defining the API with code splitting, performing queries, and altering the cache with mutations. RTK Query proves to be a great tool that we can use for data fetching and caching. We can even use the Redux DevTools extension to view our cached data through the developer tools. It does a lot of the job for us and makes a lot of sense especially if we already use Redux in our application.
Great article!
One nitpick:
is doing a bit much. You can either use Object.assign to update an existing object or create and assign a new object, both is not necessary. It behaves like an immer reducer here.
So either
or
When I tried to use the query hook in a component, it returns an error. In my case the data is an object so the error is ‘Object is of type unknown’
Please share what the Photo type is please – import Photo from ‘./photo’;
This is a fantastic introduction that cleared up a lot of my initial confusion with using RTK Query. Thank you for taking the time to write and share this!