Redux Toolkit is the official way to write Redux apps

Stephen Hanson and Will Larry

If you’ve used Redux before, you’ve probably at some point felt what I call “boilerplate pain”. That is, needing to write a large amount of code to accomplish simple tasks. There have been some recent developments with Redux that aim to fix this and other issues. If it’s been a while since you’ve used Redux, or if you haven’t followed recent developments, it might be time to look again!

Redux Toolkit is a library that entered the scene a few years ago, with a goal of simplifying Redux development. It wraps around the Redux core, providing a simpler interface for working with Redux. It also comes built-in with commonly-needed functionality that previously required reaching for additional packages.

As of Redux 4.2.0, which was released April 2022, Redux has deprecated its createStore API, and Redux Toolkit is the only officially recommended approach for writing Redux apps.

In this article, we will take a look at Redux Toolkit and will make a simple app to compare it with plain Redux.

Here’s what Redux Toolkit is aiming to solve

  • Redux requires a lot of boilerplate code
  • Configuring the Redux store is complicated
  • Redux requires adding additional packages for common use-cases

Introducing Redux Toolkit

Redux Toolkit aims to solve these challenges. From the Redux Getting Started guide:

Redux Toolkit is our official recommended approach for writing Redux logic. It wraps around the Redux core, and contains packages and functions that we think are essential for building a Redux app. Redux Toolkit builds in our suggested best practices, simplifies most Redux tasks, prevents common mistakes, and makes it easier to write Redux applications.

Redux Toolkit combines the reducer, action creator, and action concepts from Redux into a single concept called slices. This reduces the number of concepts that developers need to learn along with the amount of code developers need to write, especially for apps using TypeScript. Redux Toolkit connects the pieces without requiring as much manual “glue”.

In addition, Redux Toolkit comes with Redux Thunk built in, which returns a function instead of objects from redux action. Redux Thunk allows us to delay our redux actions, dispatch them asynchronously, and resolve any promises that get returned. Redux Toolkit includes a wrapper on top of Redux Thunk, making it simpler to work with.

Immer

Finally, Redux Toolkit also includes Immer. Immer describes itself as:

a tiny package that allows you to work with immutable state in a more convenient way.

~Redux Toolkit, by including Immer, allows developers to write updates to their state as if they are mutable. Under the hood, Immer converts those updates into immutable ones.~

In plain Redux, one of the main rules was never to change the state directly because it caused bugs, made it harder to write tests, making it difficult to understand how the state has been updated, and made it tough to use the ‘time-travel debugging’ correctly.

// ✅ This is safe, because we made a copy
function handwrittenReducer(state, action) {
  return {
    ...state,
    company: {
      ...state.company,
      department: {
        ...state.company.department,
        team:
          ...state.company.department.team,
          users:  action.filteredUsers,
      },
    },
  };
}

Redux Toolkit, which includes Immer, allows developers to write updates to their state as if they are mutable. Under the hood, Immer converts those updates into immutable ones. It operates by recording efforts to mutate an existing state or return a new value for the state. A good rule of thumb when using Immer is to mutate the existing state without returning a value or create and return a new state value to replace the existing state. Below you can find Immer Usage Patterns.

Caution: The ‘mutating’ logic only works if wrapped inside Immer. Anywhere else will mutate the state and cause bugs.

const todosSlice = createSlice({
  name: "todos",
  initialState: [],
  reducers: {
    // Change existing state without returning a value
    changeExistingState(state, action) {
      state.push(action.payload)
    },
    // Create and return a new state value
    newState(state, action.payload) {
      return state.filter(user => user.id !== action.payload)
    },
    // Create new immutably array and mutate existing state
    newArrayMutateExistingState(state, action.payload) {
      const filteredUsers = state.company.department.team.users.filter(user => user.id !== action.payload)
      state.company.department.team.users = filteredUsers
    },
  },
});

Redux Toolkit Code Samples

Now that we’ve introduced Redux Toolkit, let’s take a look at what a Redux app looks like with and without Redux Toolkit. We will use TypeScript, since that is our default for most apps. If you don’t use TypeScript, you can ignore most of the type lines.

Before – Plain Redux

// src/store/counter/types.ts
// Declare our Redux actions
// Redux actions explain what is happening in the app.
export type CounterState {
  total: number;
}

export enum actionTypes {
  INCREMENT = "counter/increment",
  DECREMENT = "counter/decrement",
  INCREMENT_BY_AMOUNT = "counter/incrementByAmount",
}

export type IncrementAction {
  type: types.INCREMENT;
}

export type DecrementAction {
  type: types.DECREMENT;
}

export type IncrementByAmountAction {
  type: types.INCREMENT_BY_AMOUNT;
  payload: number;
}

export type Action =
  | IncrementAction
  | DecrementAction
  | IncrementByAmountAction;
// src/store/counter/actions.ts
// Declare action creators
// Action creators allow us to reuse the same action object when calling Redux actions
import { types, IncrementAction, DecrementAction } from './types';

export const incrementCount = (): IncrementAction => ({
  type: types.INCREMENT,
});

export const decrementCount = (): DecrementAction => ({
  type: types.DECREMENT,
});

export const incrementAmount = (num: number): IncrementByAmountAction => ({
  type: types.INCREMENT_BY_AMOUNT,
  payload: num,
});
// src/store/counter/reducer.ts
// Declare Reducers
// Reducers are event listeners that handle the data depending on which action type is being executed.
import { CounterState, types, Action } from './types';
import { Reducer } from 'redux';

export const initialState: CounterState = {
  total: 0,
  name: 'counter example',
};

export const reducer: Reducer<CounterState, Action> = (
  state = initialState,
  action
) => {
  switch (action.type) {
    case types.INCREMENT:
      return { ...state, total: state.total + 1 };
    case types.DECREMENT:
      return { ...state, total: state.total - 1 };
    case types.INCREMENT_BY_AMOUNT:
      return { ...state, total: state.total + action.payload };
    default:
      return state;
  }
};

After – Redux Toolkit

// src/store/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

export type CounterState = {
  total: number;
};

const initialState: CounterState = {
  total: 0,
};

export const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment: (state) => {
      // Redux Toolkit allows us to write "mutating" logic in reducers. It
      // doesn't actually mutate the state because it uses the Immer library,
      // which detects changes to a "draft state" and produces a brand new
      // immutable state based off those changes
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload;
    },
  },
});

export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export const counterReducer = counterSlice.reducer;

As you can see, the Redux Toolkit approach requires significantly less code, since it combines the action creator, action type, reducer all into the single “slice” concept.

Store configuration

The store configuration is similar in Redux Toolkit vs regular Redux. Instead of the now deprecated Redux createStore function, Redux Toolkit provides a configureStore function with a slightly simplified API. Additionally, since Redux Toolkit comes with Redux Thunk, this is configured by default.

Here are examples of configuring the above app with Redux Toolkit and plain Redux

Before - Plain Redux

// src/store/store.ts
// Instantiate the Redux store
import { createStore, combineReducers } from 'redux';
import {
  reducer as counter,
  increment,
  incrementByAmount,
  decrement,
} from './counter';

const store = createStore(combineReducers({ counter }));

store.dispatch(increment());
store.dispatch(incrementByAmount(5));
store.dispatch(decrement());

After - Redux Toolkit

// src/store/store.ts
import { configureStore } from '@reduxjs/toolkit';
import { combineReducers } from 'redux';
import { counterReducer } from './counterSlice';
import { increment, incrementByAmount, decrement } from './counter';

export const store = configureStore({
  reducer: combineReducers({
    counter: counterReducer,
  }),
});

store.dispatch(increment());
store.dispatch(incrementByAmount(5));
store.dispatch(decremented());

Async Thunks with Redux Toolkit

Thunks are the standard approach for writing async logic in Redux apps and are commonly used for data fetching. At thoughtbot, we recommend letting a dedicated API client library, like TanstackQuery or RTK Query handle data fetching, but some folks might still be using thunks for other things. Redux Toolkit includes a method, createAsyncThunk, that wraps around Redux Thunk, providing a simpler API. It takes three parameters: an action type, a callback function that returns a promise, and an options object.

export type UserState = {
  loading: boolean;
  total: number;
  user: User | null;
};

export const initialState: UserState = {
  loading: false,
  total: 0,
  user: null,
};

export const fetchUser = createAsyncThunk(
  // action type string
  'users/fetchUser',
  // callback function
  (userId: string) => api.get(`user/${userId}`) as UserResponse
);

export const userSlice = createSlice({
  name: 'user',
  initialState,
  // reducers react to action types created by the current 'slice.'
  // Ex: total is included in the user slice, so we will add increment action to reducers
  reducers: {
    increment: (state) => {
      state.total += 1;
    },
  },
  // extraReducers reacts to action types NOT created by the current 'slice'
  extraReducers: (builder) => {
    // builder.addCase allows us to handle a specific action type
    // Ex: addCase will only fire when fetchUser.pending is the action type
    builder.addCase(fetchUser.pending, (state) => {
      state.loading = true;
    });
    builder.addCase(fetchUser.fulfilled, (state, action) => {
      state.loading = false;
      state.user = action.payload.user;
    });
    // builder.addMatcher allows us to handle any action types that meets our conditional
    // Ex: addMatcher will fire if with any action type that ends with /rejected
    builder.addMatcher(
      (action): action is RejectedAction => action.type.endsWith('/rejected'),
      (state, action) => {
        state.loading = false;
        state.user = null;
      }
    );
    // builder.addDefaultCase fires if no other builder callback is matched with the action type
    // Ex: addDefaultCase will fire if no action type is found
    builder.addDefaultCase((state, action) => {
      state.error = 'Could not find action type to fire';
    });
  },
});

export const userReducer = userSlice.reducer;

Redux Toolkit payload is not large

Any web application should carefully consider payload size of its JavaScript in order to maintain a fast loading time and be accessible to users on slower or limited-bandwidth connections.

While Redux Toolkit itself is a small library, it also pulls in a couple larger libraries—Immer, Redux Thunk, and Reselect.

To get an understanding of how much larger the Redux Toolkit library is, we created a React app using create-react-app. We then added regular react-redux and created a basic store and reducer and used it in the app. Finally, we added redux-toolkit and converted our store and reducer to use Redux Toolkit.

Here are the full payload sizes of our app along the way:

Description Size Increase
Initial React app 46.37 kB
Add Redux, create and use a reducer 49.16 kB + 2.79 kB
Add and use Redux Toolkit 54.53 kB +5.37 kB

As you can see, the increased payload size is fairly modest, though it might be worth noting and assessing if it is worth it for your app. If the bundle is gzipped in transit, then the increase would be even less.

The reason the payload increase is smaller than might be expected is due to optimizations of the build process, called tree shaking, that remove unused code. Since our sample app didn’t use createAsyncThunk, it’s possible that some of the Redux Thunk code didn’t find its way into the final bundle.

Conclusion

In conclusion, Redux Toolkit is a great tool for simplifying Redux development. It should be the default for any new Redux project. Development teams should consider if it makes sense to pull it into their existing Redux apps while weighing some of the considerations listed in the section above.