Migrating Redux State Management to Redux Toolkit and RTK Query
--
At GumGum, our front-end stack is mostly the same across all applications: React, Redux, and our open-source design system. Although they are pretty cookie-cutter every time we create a new application, engineers are encouraged to research new technologies that could benefit our development process. As all front-end developers know, the web development scene is ever-evolving, so implementing new technologies also helps keep your skills and knowledge up to date.
With this benefit in mind, I recently implemented Redux Toolkit (RTK) and RTK Query in our new publisher-facing application. If you are looking to migrate your front-end application to RTK and RTK Query and you don’t know where to start, look no further. Here’s a guide on how to migrate your first actions and reducers.
Reducer Setup
You might be used to the common Redux reducer setup with switch statements, like the following:
import { initialState } from "../store";
const userReducer = (state = initialState.user, action) => {
const { type, response, userData, errors } = action;
switch (type) {
case "SET_USER_DATA": {
return {
...state,
...userData,
};
}
case "LOGOUT":
return initialState;
default:
return state;
}
};
export default userReducer;
In RTK, we use createSlice
to create a slice reducer that generates corresponding action creators and action types:
import { createSlice } from "@reduxjs/toolkit";
const initialState = {};
export const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
setUserData: (state, action) => action.payload,
logout: () => initialState,
},
});
export const { setUserData, logout } = authSlice.actions;
export default authSlice.reducer;
You can see the generated actions being exported at the end. In Redux, actions are objects with a string type and payload:
export const setUserData = (userData) => ({
type: "SET_USER_DATA",
userData,
});
These are no longer necessary as the generated actions can be used directly in the component.
RTK Query
Next we can look at actions handling API calls with RTK Query. RTK Query makes it easier to manage asynchronous data in your application. With RTK Query, you can declaratively describe your data dependencies and have the data automatically loaded and made available to your components as props.
Setting Up the Base API
Here’s the setup:
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import { BASE_API_URL } from "./constants";
export const baseApi = createApi({
reducerPath: "api",
baseQuery: fetchBaseQuery({
baseUrl: BASE_API_URL,
prepareHeaders: (headers, { getState }) => {
const jwt = getState().auth.jwt;
headers.set("authorization", `Bearer ${jwt}`);
return headers;
},
}),
tagTypes: ["Users"],
endpoints: () => ({}),
});
We can include a JWT in the headers of our API calls once the user is authorized and user data is set in the store. The tagTypes
are related to RTK Query’s cache control, which will be explained below. Finally, the endpoints return an empty object because we will be code-splitting our API slices according to the collections of data that they handle and injecting them.
Actions
Here’s what our Redux API action looks like:
export const login = (email, password, callback) => ({
[CALL_API]: {
callback,
types: [types.LOGIN_REQUEST, types.LOGIN_SUCCESS, types.LOGIN_FAILURE],
endpoint: loginEndpoint,
options: {
method: "POST",
body: JSON.stringify({ email, password }),
},
},
});
Our applications use a middleware to interpret our API calls so yours may look different. The user data response is then set in the store with the previous setUserData
action.
With RTK Query, we inject new endpoints to the base API we previously defined:
import { baseApi } from "../api";
export const authApiSlice = baseApi.injectEndpoints({
endpoints: (builder) => ({
login: builder.mutation({
query: ({ email, password }) => ({
url: "/login",
method: "POST",
body: { email, password },
}),
}),
}),
});
export const { useLoginMutation } = authApiSlice;
RTK Query auto-generates the hooks name based on the following convention:
use
, the normal prefix for any React hook- The name of the endpoint, capitalized
- The type of the endpoint,
Query
orMutation
, each type being used differently in your component.
Queries are used to retrieve data. They take arguments and a set of options to control fetching behavior and return the result from the API call, request metadata, and a refetch
function to force re-fetching:
const { data, error, isLoading } = useGetDataQuery(args, options);
The data can then be used in the component itself.
Mutations are used to alter data. They take a set of options to control the hook’s subscription behavior. The hook ‘subscribes’ the component to keep cached data in the store and ‘unsubscribes’ when the component un-mounts. It returns a tuple containing the trigger function used to update data, as well as the mutation’s state. The following shows the login mutation used in our login component:
const [login, { isLoading }] = useLoginMutation();
// login: trigger function
const handleSubmit = async (e) => {
e.preventDefault();
if (isFormValid) {
try {
const { jwt } = await login({ email, password }).unwrap();
const { user } = parseJWT(jwt);
dispatch(setUserData({ user, jwt }));
return navigate("/home");
} catch ({ data: { message } }) {
console.log(message);
}
}
};
The result metadata includes a convenient isLoading
property that can be used to control your loading UI. The returned trigger promise contains an unwrap
property, which can be used to provide the data or error inline. In the example above, we are using previously auto-generated reducer action to set the response in our store, making it usable to the rest of the application.
Reducing More Code
In our Redux app, we modified the middleware previously mentioned to accept a callback. We use this to pass GET queries on data we’ve updated to refresh the data in our UI. With RTK Query, this is handled with tags. Here’s an example handling a collection of users:
GET
getUsers: builder.query({
query: () => ({
url: '/users',
}),
providesTags: ['Users'],
}),
POST
updateUser: builder.mutation({
query: ({ userId, payload }) => ({
url: `/users/${userId}`,
method: 'POST',
body: payload,
}),
invalidatesTags: ['Users'],
}),
RTK Query uses the providesTags
and invalidatesTags
properties to control caching of a collection of data. In this case, we are controlling the data associated with ‘Users’, and when we send the POST request, that cached data is invalidated and it triggers the GET request that provides the same tag to refresh the data. Now we can remove any re-fetching logic we are manually creating for similar patterns.
Optimistic Updates
In a Redux app, a common way to handle an optimistic update would be to send a request type with your API action, and when you receive the response, send another action with a fulfilled type and the response data to update the store. In RTK query, you can handle optimistic updates inline within endpoint:
deleteUser: builder.mutation({
query: ({ userId }) => ({
url: `/users/${userId}`,
method: "DELETE",
}),
async onQueryStarted({ userId }, { dispatch, queryFulfilled }) {
const optimisticDelete = dispatch(
baseApi.util.updateQueryData("getUsers", userId, (users) =>
users.filter(({ user: { id } }) => !users.includes(userId))
)
);
try {
await queryFulfilled;
} catch (e) {
console.log({ e });
dispatch(
sendNotification({
text: i18n.t("errorMessages.general"),
neverFade: true,
context: "danger",
})
);
optimisticDelete.undo();
}
},
});
In the example above, we are defining our optimistic update logic in the onQueryStarted
function to filter the user we are deleting, updating the UI immediately.
Redux DevTools
RTK Query is also supported in Redux DevTools, showing the API call’s response, as well as metadata, such as status and timings:
Store Setup
Now that we have our reducer and actions set up, we can connect them to the store. Here’s the store set-up importing our new reducer:
import { configureStore } from "@reduxjs/toolkit";
import baseApi from "./api/api";
// Reducers
import authReducer from "./features/login/authReducer";
export const store = configureStore({
reducer: {
auth: authReducer,
[baseApi.reducerPath]: baseApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false,
}).concat(baseApi.middleware),
});
export default store;
And now you are finished migrating your first set of reducers and actions to RTK. Because RTK is built on top of Redux, you can include your current reducers and migrate them piece by piece.
Bonus Round — Notification Middleware
If your application has some sort of global notification display like a snackbar, or you would like to implement one, here’s a middleware that I created to handle those messages triggered by your API calls:
import { isFulfilled, isRejectedWithValue } from "@reduxjs/toolkit";
import { sendNotification } from "./notificationsReducer";
import i18n from "../../translations/i18n";
import notifications from "../../constants/notifications";
const MS_TO_CLOSE = 5000;
const notificationMiddleware =
({ dispatch }) =>
(next) =>
(action) => {
if (action.meta) {
const {
requestStatus = "",
arg: { endpointName },
} = action.meta;
const context = {
pending: "warning",
fulfilled: "success",
rejected: "danger",
};
if (notifications[endpointName] || isRejectedWithValue(action)) {
dispatch(
sendNotification({
text: isRejectedWithValue(action)
? i18n.t("errorMessages.general")
: `${notifications[endpointName].replace(
"[tense]",
isFulfilled(action) ? "ed" : "ing"
)}`,
context: context[requestStatus],
...(isRejectedWithValue(action)
? {
neverFade: true,
}
: {
endpointName,
requestStatus,
}),
...(isFulfilled(action)
? { msToClose: MS_TO_CLOSE }
: {}),
})
);
}
}
return next(action);
};
export default notificationMiddleware;
Let’s go through it line by line. First, RTK provides isFulfilled
and isRejectedWithValue
functions to check the status of your action. sendNotification
is an action to set the message data to the store, which will be displayed by our snackbar component. We use i18next to translate our notifications to other languages. The notifications file holds our messages based on the endpoint string. Then we have the logic for the behavior and look of the snackbar, such as the message, the color of the snackbar depending on the context, and whether to automatically fade the notification after 5 seconds or require user action to close it. Finally, you must .concat(notificationMiddleware)
in the store we set up above to parse API actions.
Conclusion
Redux Toolkit and RTK Query can significantly improve the state management and data fetching experience in a React application. Redux Toolkit simplifies the process of setting up and managing the store, while RTK Query provides an easy and efficient way to handle data fetching and caching. With both of these tools, developers can build more scalable and maintainable applications, freeing up time to focus on building great user experiences and delivering value to their users.