Written by Stephan Miller
✏️
State management in web applications is a hot topic. But while React's Context API, MobX, and a handful of other libraries might be great alternatives to Redux, Redux is still king.
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
andcreateReducer
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
replacesAnyAction
: Treat any action's fields asunknown
unless explicitly checked. Use type guards like.match()
from Redux Toolkit or the newisAction
utility to verify action types before accessing fields -
Middleware
action andnext
parameters are alsounknown
: Use type guards to safely interact with actions within the middleware -
PreloadedState
type is gone: It has been replaced by a generic in theReducer
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
toimmediate
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.
Get set up with LogRocket's modern error tracking in minutes:
- Visit https://logrocket.com/signup/ to get an app ID.
- Install LogRocket via NPM or script tag.
LogRocket.init()
must be called client-side, not server-side.
NPM:
$ npm i --save logrocket
// Code:
import LogRocket from 'logrocket';
LogRocket.init('app/id');
Script Tag:
Add to your HTML:
<script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
<script>window.LogRocket && window.LogRocket.init('app/id');</script>
3.(Optional) Install plugins for deeper integrations with your stack:
- Redux middleware
- ngrx middleware
- Vuex plugin
Top comments (1)
Hey there, thanks for the article!
Just a couple tweaks needed in the examples:
In the
Tuple
example you returnednew Tuple(getDefaultMiddleware(), myCustomMiddleware, logger)
, which isn't valid asgetDefaultMiddleware
returns aTuple
instance itself, which you can then callconcat
orprepend
on as required. You only need to construct aTuple
yourself in the rare (not recommended) case that you're not callinggetDefaultMiddleware
at all:new Tuple(myCustomMiddleware, logger)
.In the slice selector examples, the slice selectors still need to be called with state as their first argument:
Other than these, this is a nice round up of the major version changes :)