Stephan Miller

Exploring Redux Toolkit 2.0 and the Redux second generation

Redux has earned its stripes. It’s predictable, reliable, and has a huge community of users. But even those of us who use it have to be honest: there used to be a lot of boilerplate to deal with, which added complexity and could make tracing variables through your source code a pain.

But if you’re still dealing with this boilerplate, then you need to catch up. Redux Toolkit has been around since 2019 and is now the standard method of creating Redux apps, streamlining your state management, and reducing the amount of boilerplate code you need to write. And if you are already using Redux Toolkit and RTK Query, Redux Toolkit 2.0 was released to production in November 2023, so it’s ready to use.

Redux Toolkit 2.0 overview and installation

Redux Toolkit 2.0 is the first major version of Redux Toolkit in four years and while it’s a big overhaul and there are some breaking changes, Redux documentation states that “most of the breaking changes should not have an actual effect on end users” and that “many projects can just update the package version with very few code changes.” Here is an overview of the changes:

Modernization

  • Packaging updates: The modern ESM build lives in ./dist/ with a CJS build included for compatibility
  • Deprecations removed: Several options that were marked as deprecated in the past have been removed, such as the outdated object syntax in slices and reducers

Better workflow

  • New combineSlices method: Lazy load slice reducers for improved performance and modularity
  • Object vs. callback syntax: Both createSlice and createReducer now use a cleaner callback syntax instead of the deprecated object approach
  • Dynamic middleware: You can now add middleware on the fly

Dependency changes

  • Updated dependencies, like Reselect and Redux Thunk, which you don’t have to install separately
  • Requires TypeScript 4.7 or later for optimal compatibility
  • Requires React Redux 9.0 for React apps, which you have to install separately
  • Requires React 18 if you’re using React Redux

Installing Redux Toolkit

Now that we have an idea of the changes and improvements in this new version of Redux Toolkit, let’s look at how to migrate a web app to this new version. If you are still using old school, non-Toolkit Redux, I will point you to other posts along the way that will guide you through migrating to the newer way of using Redux.

The first step is to install the new version, which is v2.0.2 at the time of writing this article:

# with npm
npm install @reduxjs/toolkit
# or with yarn
yarn add @reduxjs/toolkit

This will bring Redux core 5.0, Reselect 5.0, and Redux Thunk 3.0 along with it. If you are installing this in a React app, the new version of React Redux requires updating to React 18.

Once you have upgraded React or if you are already running this version, install React Redux 9.0 with one of these commands:

# with npm
npm install react-redux
# or with yarn
yarn add react-redux

Moving to Redux Toolkit 2.0 and Redux core 5.0

If you are still using vanilla Redux, you should check out this article on moving to Redux Toolkit. This installation won’t change that because you can still use vanilla Redux with this version, but who would want to? Redux Toolkit changes the following three files into one file:

// Actions
const ADD_TODO = 'ADD_TODO';

function addTodo(text) {
  return { type: ADD_TODO, payload: text };
}

// Reducer
function todoReducer(state = [], action) {
  switch (action.type) {
    case ADD_TODO:
      return [...state, action.payload];
    default:
      return state;
  }
}

// Store
import { createStore } from 'redux';

const store = createStore(todoReducer);

Here is the resulting file:

import { createSlice } from '@reduxjs/toolkit';

const todoSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    addTodo(state, action) {
      state.push(action.payload);
    },
  },
});

export const { addTodo } = todoSlice.actions;
export default todoSlice.reducer;

Redux ToolKit changes in 2.0

Now let’s look at how Redux Toolkit improved in the latest version and what changes have to be made during an upgrade.

Minor TypeScript changes

Here are some of the simple changes you have to make because of TypeScript compatibility updates:

  • UnknownAction replaces AnyAction: Treat any action’s fields as unknown unless explicitly checked. Use type guards like .match() from Redux Toolkit or the new isAction utility to verify action types before accessing fields
  • Middleware action and next parameters are also unknown: Use type guards to safely interact with actions within the middleware
  • PreloadedState type is gone: It has been replaced by a generic in the Reducer type

Callback syntax in createSlice is now required

This change applies to both createSlice.extraReducers and createReducer. Up until this version, you could use either type of syntax. Here is an example of how to make this change.

This is the code block before making the change. We’re using the object syntax:

const mySlice = createSlice({
  // ... other reducers
  extraReducers: {
    [fetchTodos.pending]: (state) => {
      state.status = 'loading';
    },
    [fetchTodos.fulfilled]: (state, action) => {
      state.todos = action.payload;
      state.status = 'idle';
    },
    [fetchTodos.rejected]: (state, action) => {
      state.status = 'error';
    },
  },
});

And this is after the change. We’re using the callback syntax:

const mySlice = createSlice({
  // ... other reducers
extraReducers: (builder) => {
    builder
      .addCase(fetchTodos.pending, (state) => {
        state.status = 'loading';
      })
      .addCase(fetchTodos.fulfilled, (state, action) => {
        state.todos = action.payload;
        state.status = 'idle';
      })
      .addCase(fetchTodos.rejected, (state, action) => {
        state.status = 'error';
      });
  },
})

Changes to configureStore

According to the Redux docs, createStore is now deprecated and configureStore should be used instead. However, this has been the case since version 4.2.0, so it is not a new development. They are just reiterating this; createStore won’t be removed because configureStore uses it internally, but it shouldn’t be used directly.

Both configureStore.middleware and configureStore.enhancers must now be callbacks. Here is an example of these changes:

import { configureStore } from '@reduxjs/toolkit';
import logger from 'redux-logger';
import { batchedSubscribe } from 'redux-batched-subscribe';

const store = configureStore({
  // other configuration options
  middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger),
  // NOT THIS: middleware: (getDefaultMiddleware) => return [myMiddleware],
  enhancers: (getDefaultEnhancers) => getDefaultEnhancers().concat(batchedSubscribe()),
  // NOT THIS: enhancers: (getDefaultEnhancers) => return [myEnhancer],
});

The order of middleware and enhancers matters. For internal type inference to work, middleware has to come first.

You now have to use the Tuple type to provide an array of custom middleware or enhancers to configureStore. A plain array often leads to type loss, while Tuple maintains type safety. Here is an example:

import { configureStore, Tuple } from '@reduxjs/toolkit';
import logger from 'redux-logger';

configureStore({
  reducer: rootReducer,
  middleware: (getDefaultMiddleware) =>
    new Tuple(getDefaultMiddleware(), myCustomMiddleware, logger),
});

Changes to customizing reactHooksModule

Previously you could introduce your own custom versions of useSelector, useDispatch, and useStore but there was no way to check that all three were added. This module is now under the key of hooks and there is a check to determine whether all three exist:

// What you could do before
const customCreateApi = buildCreateApi(
  coreModule(),
  reactHooksModule({
    useDispatch: createDispatchHook(MyContext),
    useSelector: createSelectorHook(MyContext),
  })
);

// How you do it now
const customCreateApi = buildCreateApi(
  coreModule(),
  reactHooksModule({
    hooks: {
      useDispatch: createDispatchHook(MyContext),
      useSelector: createSelectorHook(MyContext),
      useStore: createStoreHook(MyContext),
    },
  })
);

New Thunk support in createSlice.reducers

Redux Toolkit 2.0 introduces the ability to add async thunks within createSlice.reducers.

To do so, first set up a custom version of createSlice using buildCreateSlice with access to createAsyncThunk. Then, use a callback for reducers to define thunks and other reducers. Finally, employ create.asyncThunk within the callback.

Here is an example:

const createSliceWithThunks = buildCreateSlice({
  creators: { asyncThunk: asyncThunkCreator },
});

const todosSlice = createSliceWithThunks({
  name: 'todos',
  reducers: (create) => ({
    // Normal reducers
    deleteTodo: create.reducer(...),
    // Async thunk
    fetchTodo: create.asyncThunk(
      async (id, thunkApi) => {
        const res = await fetch(`myApi/`);
        return (await res.json());
      },
      {
        pending: (state) => { ... },
        fulfilled: (state, action) => { ... },
        rejected: (state, action) => { ... },
        settled: (state, action) => { ... },
      }
    ),
  }),
});

// Access thunks like regular actions using slice.actions.
export const { addTodo, deleteTodo, fetchTodo } = todosSlice.actions;

Making selectors part of your slice

You can now define selectors directly within createSlice. Here are some points to note:

  • Selectors assume the slice state is mounted at rootState.{sliceName}
  • Use sliceObject.getSelectors(selectSliceState) to customize selector generation for alternate state locations

Here’s a code example:

const mySlice = createSlice({
  name: 'todos',
  reducers: {
    // ... reducers
  },
  selectors: {
    selectTodos: state => state.todos,
    selectTodoById: (state, todoId) => state.todos.find(todo => todo.id === todoId),
  },
});

// Accessing selectors:
const { selectTodos, selectTodoById } = mySlice.selectors;

const todos = selectTodos();
const todo = selectTodoById(42);

Lazy loading and code split slices

Redux Toolkit 2.0 introduces combineSlices to enable code splitting and lazy loading reducers. It accepts individual slices or an object of slices and automatically merges them using combineReducers. The reducer function it generates provides the following methods:

  • inject(): Adds slices dynamically, even after the store is created
  • withLazyLoadedSlices(): Generates TypeScript types for slices to be added later

Here is an example:

// Combine slices and add lazy loaded type
import { combineSlices } from '@reduxjs/toolkit';
import slice1 from './slice1';
import slice2 from './slice2';
import lazyLoadedSlice from './lazyLoadedSlice';

const rootReducer = combineSlices(slice1, slice2).withLazyLoadedSlices<
    WithSlice<typeof lazyLoadedSlice>
  >();

// Later, inject new slice lazy loaded slice:
import lazyLoadedSlice from './lazyLoadedSlice';

rootReducer.inject(lazyLoadedSlice);

Dynamically add middleware

It used to take a hack or a separate package to add middleware at runtime, which can be useful for code splitting. Now you can do this with Redux Toolkit 2.0:

// Import, create dynamic instance, and configure your store with it.
import { createDynamicMiddleware, configureStore } from '@reduxjs/toolkit'

const dynamicMiddleware = createDynamicMiddleware()

const store = configureStore({
  reducer: {
    myThings: myThingsReducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().prepend(dynamicMiddleware.middleware),
})

// Add your middleware at runtime
dynamicMiddleware.addMiddleware(loggerMiddleware);

// Add other middleware based on conditions, user input, etc.
if (someCondition) {
  dynamicMiddleware.addMiddleware(otherMiddleware);
}

createDynamicMiddleware also comes with React hook integration (if you have React Redux 9.0 installed).

Reselect 5.0: Changes and new features

Reselect now uses a WeakMap-based memoization function called weakMapMemoize by default. It offers better performance and memory management compared to the previous defaultMemoize function. The cache size is effectively infinite, but it now relies exclusively on reference comparison.

The older defaultMemoize function is now available as lruMemoize for those who need a Least Recently Used (LRU) cache. If you want to create custom equality comparisons, you can make createSelector use lruMemoize.

You can then pass options to createSelector for more control over memoization and debugging:

  • memoize: Specifies a custom memoization function (e.g., lruMemoize)
  • argsMemoize: Customizes memoization behavior for selector arguments
  • inputStabilityCheck: Enables a development-time check for input selector stability
  • identityFunctionCheck: Warns if the result function returns its input directly

Here is an example of specifying the older memoize function instead of the default weakMapMemoize along with some of these new options:

import { createSelector } from 'reselect';

const mySelector = createSelector(
  state => state.todos,
  todos => todos.filter(todo => todo.completed),
  {
    memoize: lruMemoize, // Use LRU cache, runs the input selectors and compares their current results with the previous ones
    memoizeOptions: { resultEqualityCheck: (a, b) => a === b } // Custom equality comparison
    argsMemoize: defaultMemoize, // Use default memoize function, compares the current arguments with the previous ones
    argsMemoizeOptions: { isEqual: (a, b) => a === b }, // Custom equality comparison for argsMemoize
    inputStabilityCheck: true, // Enable input stability check
  }
);

Changes to RTK Query 2.0

Now, if you aren’t using Redux Toolkit, you definitely aren’t using RTK Query. I started using Toolkit around two years ago and just happened to run into RTK Query about six months ago when I was looking for a simplified way of fetching data for the dashboard. I wish I had found it earlier!

While it doesn’t replace React Toolkit, RTK Query is great for fetching data. It will even take out your service files if you currently have them, which means less boilerplate.

Only a few things were changed in RTK Query 2.0. The development team stated that the focus for 2.0 was improvements to the core Redux Toolkit libraries and now that they’re done with that, they can shift attention to improving the RTK Query library. But some issues were fixed, including:

  • Some users reported issues with manually skipping subscriptions and running multiple lazy queries. These bugs were due to RTK Query not tracking cache entries in certain scenarios
  • Running multiple mutations consecutively could cause problems with tag invalidation (updating related data based on changes). RTK Query now lets you choose how tag invalidation happens. By default, it waits briefly to group multiple invalidations, preventing unnecessary processing, but if you prefer the old behavior, you can switch it back in the configuration by setting invalidationBehavior to immediate

Not much changed with React Redux 9.0

Redux Toolkit 2 requires React Redux 9 in React-based apps. The changes to React Redux were relatively minor, mainly to make it compatible with the other Redux changes.

Conclusion

Redux Toolkit 2.0 is here, and it is not yesterday’s Redux, but it hasn’t been for a while. Redux Toolkit and RTK Query have been around for four years now and reduced a lot of the boilerplate, which was the biggest complaint about Redux.

But this new version adds even more reasons to give it a try, including streamlined, modern packaging and the removal of outdated and deprecated features. Slices can now be lazy loaded and string-based action types simplify debugging. Finally, upgrading doesn’t require many code changes and, according to the docs and my experience, won’t affect your users.

Stephan Miller

Written by

Kansas City Software Engineer and Author

Twitter | Github | LinkedIn

Updated