Go Back

Supercharge your dev productivity with Redux Toolkit

Posted on April 15, 2023|14 min read

Redux is a powerful state management library for JavaScript applications, but configuring and using it can be cumbersome. Redux Toolkit is a recommended way of using redux as per the documentation. It is a wrapper around Vanilla Redux that simplifies the process of working with Redux by providing a set of opinionated defaults and utilities that help you write Redux code faster and with fewer bugs.

In this post, we'll explore how Redux Toolkit can help supercharge your development productivity by eliminating boilerplate code, simplifying reducers, making immutable updates easier, and integrating with the Redux DevTools extension. To understand how to work with Redux Toolkit, we will be implementing state management for a multi user task application using the library 📝.

As per Shopify Engineering, migrating from Vanilla Redux to Redux Toolkit led them to delete around 3500 lines of codes 😱🤩 that had no purpose other than boilerplating.

The Problems with Vanilla Redux

Redux is powerful, but it has a steep learning curve. Configuring a Redux store involves a lot of boilerplate code, and writing reducers can be tricky. Here are some common issues developers face when using vanilla Redux:

Boilerplate Code

Setting up a Redux store requires a lot of boilerplate code. You need to create a store, define actions and, write reducers. All of this code can be repetitive, boring 😑 and error-prone. We have to write a lot of code just to make sure the reducers know what to do.

Immutable Updates

When updating a state in a Redux store, you need to create a new state object that is a copy of the old state with the changes applied. This is known as "immutable updates." Immutable updates can be difficult to get right, and they can be time-consuming to write.

Hard-to-Read Code

Reducers are the heart of a Redux application. They take an action and the current state and return a new state. Writing reducers can be tricky, and they can be hard to read and understand.

The Benefits of Redux Toolkit

Redux Toolkit is a package that simplifies the process of working with Redux. Here are some of the benefits of using Redux Toolkit:

Opinionated Defaults

One of the benefits of Redux Toolkit is that it provides a set of opinionated defaults that make it easier to write Redux code. For example, when you create a store with configureStore, Redux Toolkit automatically includes common middleware, such as redux-thunk, and it sets up the store in a way that's optimized for performance.

Simplified Reducers

Redux Toolkit provides a set of utilities that make it easier to write reducers. For example, it includes a createSlice function that generates reducer functions based on a set of defined actions. This can help eliminate boilerplate code and simplify your reducers.

Mutable Updates

Redux Toolkit includes a utility called immer that makes it easier to write immutable updates. immer allows you to write code that looks like you're mutating state, but it actually creates a new state object behind the scenes. This can make your code easier to read and understand.

Excellant Typescript Support

Typescript support by RTK is in its fullest and most glorious form. Redux Toolkit provides built-in support for TypeScript, so you can get strong typing for your actions, reducers, and selectors. For example, when you use the createSlice function, Redux Toolkit generates TypeScript types for the slice and its actions, which can help catch errors and improve your code completion.

DevTools Integration

Redux Toolkit integrates with the Redux DevTools extension, which makes it easier to debug your application. You can use the DevTools to inspect your store, view actions, and even time travel through your application's state.

Creating a multi-user Todo application

We will be creating a multi-user Todo application using Redux Toolkit. The application will have following features:

  • feature to add and remove tasks and toggling task as completed or not.
  • Add and remove users
  • Set current user.
  • Each task will be associated with a user.
  • On deleting the user, it should delete all tasks associated with that user.

We will not be using any backend for this application, so all data will be stored in the Redux store. I will not share component implementation in the post, will only share how we can define, setup redux store for this usecase using redux-toolkit and how to use it.

Introducing Slices

In Redux Toolkit, slice is a function that generates a set of Redux actions, reducers, and selectors that are specific to a certain section of the Redux store. It is a self-contained piece of the store that includes the initial state, action creators, and reducers that work together to update the state.

The createSlice method is a powerful utility function provided by the Redux Toolkit library that makes it easier to write Redux code by generating action creators and reducers for you.

Here are the parameters that the createSlice method accepts:

  • name (required): The name parameter is a string that represents the name of the slice. It is used to generate action types based on the name of the slice. For example, if the name of the slice is "users", the action types generated by Redux Toolkit will have the prefix "users/". This helps to avoid naming collisions with other slices of state.

  • initialState (required): The initialState parameter is an object that represents the initial state of the slice. It is used to generate the initial state of the reducer.

  • reducers (optional): The reducers parameter is an object that contains one or more reducer functions. Each reducer function should be a pure function that takes two parameters: the state object and an action object. The state object represents the current state of the slice, while the action object represents the action that was dispatched. We can specify the type of action payload using PayloadAction type from the library.

The reducers object should contain one property for each action that you want to handle. The name of the property should be the name of the action, and the value should be a reducer function that handles the action. Redux Toolkit will generate an action creator function for each property in the reducers object.

  • extraReducers (optional): The extraReducers parameter is an object that contains one or more reducer functions. These reducer functions can handle actions that were not handled by the reducers object. In our case, we can handle deleting all the tasks belonging to a user when that user is removed.

In our application, we can identify two different types of entites: users and tasks.

Let's define slices for each entity:

User Slice

User object will have these fields:

// features/user.ts export interface User { id: string; // userid name: string; // name of the user createdTs: number; // created Timestamp }

Since we can add users, delete users and set current user, let's define User state to handle it:

interface UserState { entities: User[]; currentUser?: User; }

To define a user slice, we need to import createSlice utility function from @reduxjs/toolkit:

import { createSlice, PayloadAction, nanoid } from '@reduxjs/toolkit';

let's assume we will always have an admin user:

const adminUser = { id: 'admin', name: 'admin', createdTs: Date.now(), };

so the initial state of user slice will have admin in the user list and current user will be set as admin:

const initialState: UserState = { entities: [ adminUser ], currentUser: adminUser, }

Next, let's define userSlice:

const userSlice = createSlice({ name: 'user', initialState, reducers: { addUser: { reducer: (state, action: PayloadAction<User>) => { state.entities.unshift(action.payload); }, prepare: (name: string) => { return { payload: { id: nanoid(), name, createdTs: Date.now(), } }; }, }, deleteUser: (state, action: PayloadAction<string>) => { state.entities = state.entities.filter(user => user.id !== action.payload); }, setCurrentUser: (state, action: PayloadAction<string>) => { state.currentUser = state.entities.find(user => user.id === action.payload); }, } });

The "user" slice has three actions:

  • "addUser",
  • "deleteUser", and
  • "setCurrentUser".

Note: The nanoid function used in our code is a utility function provided by the Redux Toolkit library for generating unique IDs for your Redux state. It is a third-party library that generates a unique string identifier.

The "addUser" action adds a new user to the state. Since we need only user name from the client and other fields like id and timestamp are generated by our system, we can use special prepare function to generates a payload containing a new ID, name, and creation timestamp.

The "deleteUser" action removes a user from the state by filtering out the user with the matching ID.

The "setCurrentUser" action sets the current user by finding the user with the matching ID.

Note: In Redux Toolkit's createSlice method, the prepare function is a special option for defining action creators. It allows you to specify a "prepare" step that generates the payload for the action creator. The purpose of the prepare function is to separate the "preparation" of the payload from the "action" itself. By using prepare in your reducer, you can define a separate function to handle the preparation of the payload that is passed to the action creator. This function can take any number of arguments, and its return value will be used as the payload property of the action object created by the action creator.

wait a minute 🤔, in the reducer which is a pure function, we are direcly mutating the state. Aren't we 😤 ?

Calm down 😌 Yes, in the code shown, we are directly mutating the state instead of make a new copy of it as we do in vanilla redux. The reason is createSlice function creates "slice reducers" that use the Immer library behind the scenes to enable state mutation. Immer provides a simple way to work with immutable data structures, making it possible to "mutate" the state in the reducers without actually mutating the original data 👯‍♀️.

In the code, when you write state.entities.unshift(action.payload), it looks like you're directly mutating the entities array in the state. However, under the hood, the Immer library creates a new copy of the entities array and modifies it, while keeping the original state object unchanged. This is possible because createSlice automatically generates the reducer function using Immer's produce function, which creates a draft copy of the state that can be safely mutated, while leaving the original state unchanged. So, although it appears as though you're mutating the state directly, you're actually mutating a draft copy of the state, which is then used to generate a new, updated state object. This is a key feature of the Redux Toolkit that makes it easier to write reducers that work with immutable data structures, while still allowing for a simpler syntax that looks like direct mutation of state.

Action creators and reducer

Great. Now, we have our user slice ready. We can export our user slice reducer and all the action creators generated by Redux toolkit's createSlice function. These action creators can be dispatched to trigger the corresponding reducers and modify the state:

export const { addUser, deleteUser, setCurrentUser } = userSlice.actions; export default userSlice.reducer;

If you hover over one of the actions, i.e. type generated for deleteUser action creator is:

const deleteUser: ActionCreatorWithPayload<string, "user/deleteUser">

where string is the id of the user ( action payload ) and "user/deleteUser" is the action type. You can see all these are autogenerated by the redux toolkit and we don't have to manage the creation of action object anymore 🥳. This results in eliminating a lot of boilerplate code and simplify our reducers.

Task Slice

Each task will have following properties:

// features/tasks.ts export interface Task { id: string; // unique identifier text: string; // task description completed: boolean; // completed flag userId?: string; // userId of the user who created the task createdTS: number; // created timestamp }

Task state interface will look like:

interface TaskState { entities: Task[]; }

Initially, we won't be having any task, so we can keep entities as empty array:

const initialState: TaskState = { entities: [], }

If you remeber the requirements, we can add a task, delete a task and mark task as completed. Also, whenever any user is removed, we need to remove all the tasks belonging to him. Let's create task slice considering all these scenarios:

const taskSlice = createSlice({ name: 'task', initialState, reducers: { addTask: { reducer: (state, action: PayloadAction<Task>) => { state.entities.unshift(action.payload); }, prepare: (text: string, userId?: string) => { return { payload: { id: nanoid(), createdTS: Date.now(), text, completed: false, userId, } }; } }, toggleTask: (state, action: PayloadAction<string>) => { const taskToComplete = state.entities.find(task => task.id === action.payload); if (taskToComplete) { taskToComplete.completed = !taskToComplete.completed; } }, deleteTask: (state, action: PayloadAction<string>) => { state.entities = state.entities.filter(task => task.id !== action.payload); }, }, extraReducers: (builder) => { builder.addCase(deleteUser, (state, action) => { state.entities = state.entities.filter(task => task.userId !== action.payload); }); } }); export const { addTask, deleteTask, toggleTask } = taskSlice.actions; export default taskSlice.reducer;

We have defined three reducers: addTask, toggleTask, and deleteTask.

  • addTask: The addTask reducer is defined as an object with two properties:

    reducer: A function that modifies the state to add a new Task object to the entities array. It uses the unshift method to add the new task object to the beginning of the array.

    prepare: A function that prepares the payload for the addTask action. It takes two parameters: text and userId. It returns an object with a payload property that includes a new Task object with the given text, userId, and generated id, createdTS, and completed properties.

  • toggleTask: The toggleTask reducer is a function that modifies the state to toggle the completed property of a Task object with the given id. It finds the Task object in the entities array using the find method, and toggles its completed property.

  • deleteTask: The deleteTask reducer is a function that modifies the state to remove a Task object with the given id from the entities array. It uses the filter method to remove the object with the matching id.

Extra reducers 🛸

In Redux Toolkit, extraReducers is an option that can be passed to createSlice() method to add additional reducers to the slice that don't directly correspond to the action creators defined in the reducers field. These extra reducers can be useful for handling actions that don't fit neatly into the existing action creator functions or for handling actions from other parts of the application. These reducers are defined outside of the reducers object and can respond to actions that are defined in other slices.

In our code, the extraReducers field is used to handle the deleteUser action which is from user slice. The builder.addCase() method is used to define how the state should be updated when the deleteUser action is dispatched. This method takes two arguments: the first argument is the action that the reducer should handle, and the second argument is a callback function that describes how the state should be updated.

In our code, the builder.addCase(deleteUser, (state, action) => { ... }) method is used to define how the state.entities array i.e. tasks should be updated when a deleteUser action is dispatched i.e. when a user is deleted. Here the task array is filtered to remove any tasks that have a userId that matches the action.payload value which is actually the payload value send for deleteUser action.

Action creators

The addTask, deleteTask, and toggleTask action creators are generated by the createSlice function based on the reducers defined in the reducers object. These action creators can be dispatched to trigger the corresponding reducers and modify the TaskState slice.

Great. We are done with creating different slices of state.

Let's define our global redux store.

Configure Redux store

To setup the redux store using Redux Toolkit, we need to import configureStore from @reduxjs/toolkit package. We also need to import user and task reducers which we have defined earlier.

import { configureStore } from '@reduxjs/toolkit'; import tasksReducer from './features/task'; import usersReducer from './features/user';

Next, we create the store using the configureStore function from Redux Toolkit. We pass an object to the reducer key with the two reducers we imported earlier. The configureStore function also includes other options for setting up a Redux store, such as middleware and dev tools.

export const store = configureStore({ reducer: { tasks: tasksReducer, users: usersReducer, }, });

After the store is created, we can infer RootState and AppDispatch directly from the store object. These types will be generated based on reducers we pass to the reducer object in configureStore function.

// Infer the `RootState` and `AppDispatch` types from the store itself export type RootState = ReturnType<typeof store.getState>; export type AppDispatch = typeof store.dispatch;

Finally, we create two custom hooks - useAppSelector and useAppDispatch. These hooks are just convenience functions that wrap the useSelector and useDispatch hooks respectively. The useAppSelector hook is typed to use the RootState type we defined earlier, and the useAppDispatch hook returns the AppDispatch type we defined.

import { useSelector, useDispatch, TypedUseSelectorHook } from 'react-redux'; export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector; export const useAppDispatch: () => AppDispatch = useDispatch;

By exporting these two hooks, we can use them throughout our application to interact with the Redux store with the support of typescript.

Using our Redux state and dispatching actions

Feature 1: Add a User

import { useAppDispatch } from '../store'; import { addUser } from '../features/user'; function AddUser() { const [text, setText] = useState(''); const dispatch = useAppDispatch(); // ... const handleSubmit = () => { // ... dispatch(addUser(text)) //... } return ( ... ) } export default AddUser;

Feature 2: Delete a user

import { deleteUser } from '../features/user'; const dispatch = useAppDispatch(); const onUserDelete = () => { dispatch(deleteUser(user.id)); }

Feature 3: Add a task

import { useAppDispatch, useAppSelector } from '../store'; import { addTask } from '../features/task'; function AddTask() { const [text, setText] = useState(''); // get current user const currentUser = useAppSelector(state => state.users.currentUser); const dispatch = useAppDispatch(); // ... const onSubmit = (event) => { dispatch(addTask(text, currentUser?.id)) } return ( ... ) } export default AddTask

Feature 4: Delete a task

import { deleteTask } from "../features/task" const dispatch = useAppDispatch(); const onTaskDelete = (id: string) => { dispatch(deleteTask(id)); }

Feature 5: toggle task completion

import { toggleTask } from "../features/task" const dispatch = useAppDispatch(); const onTaskToggle = (id: string) => { dispatch(toggleTask(id)); }

Feature 6: Set current user

import { setCurrentUser } from '../features/user'; const dispatch = useAppDispatch(); const onSelect = (userId: string) => { dispatch(setCurrentUser(e.target.value)); }

Feature 6: List all the tasks and users

const tasks = useAppSelector(state => state.tasks.entities); const users = useAppSelector(state => state.users.entities);

Summary

Redux Toolkit is a modern JavaScript state management solution that simplifies the development process by reducing the amount of boilerplate code required when building new features that require Redux. It allows developers to reuse their existing Vanilla Redux code and expertise while benefiting from the ease of use of Redux Toolkit. By removing unnecessary code, Redux Toolkit makes state management architecture easier to understand and debug, which can result in faster feature development. Overall, it's a tool that can help developers save time and improve their workflow. I hope you have enjoyed 🥳 reading this article.

You can checkout this github repo for the implemented features used in this post.

To know more about Redux Tookkit, checkout the official documentation.

Thanks and see you later !