Why do you want to master Redux in 2024 and beyond?
I've built Redux apps that scaled to hundreds of thousands of concurrent users. And in this article, I'm going to share with you the lessons I learned.
If you're a beginner, this article will teach you Redux from the ground up.
And even if you've been using Redux for years, this article will fill any gaps in your knowledge and reveal secret tricks for writing the cleanest code possible.
This article is part one of a 5-part series on Redux. The first three together will give you a deeper understanding than 98% of the market. And the last two are tutorials where you build production-ready Redux apps.
Most developers often start with two key questions:
You're going to get the answer to these questions at the end of this article because to fully understand the answers, you need to understand Redux.
Instead you're going to start with answering the question ...
... and what is Redux for?
Redux is a JavaScript library for managing application state. Redux makes it easier to handle complex state in large applications by using a single, global store, which contains the applications state.
The name "Redux" derives from the array method "reduce" mixed with the "Flux" architecture.
The "reduce"
part refers to the reducer functions in Redux that manage changes to your application’s state.
Redux design is influenced heavily by many technologies, but the main one is the Elm architecture:
This is what the "Flux" part in Redux hints at because the Flux architecture is basically the same.
Redux is made out of 6 building blocks:
In Redux, the Flux architecture translates to:
store
,This diagram shows you the data flow in Redux and how these 6 components work together.
Your React components have access to your store's dispatch
method. You'll learn how this works later in this article.
You then dispatch an action which get's passed to your middleware for processing and handling side effects before reaching your reducers.
Your reducers update the global application state.
The updated state flows back into your React components through your selectors, triggering a re-render.
This article will contain many code examples, and I highly recommend you code along because that way you will retain the most knowledge.
So create a new Next.js 15 project.
npx create-next-app@latest
And then configure your project by hitting yes
on everything except TypeScript. The third article in this series covers Redux with TypeScript.
✔ **What is your project named?** … redux-mastery-part-one
✔ **Would you like to use** **TypeScript****?** … No / Yes
No
✔ **Would you like to use** **ESLint****?** … No / Yes
Yes
✔ **Would you like to use** **Tailwind CSS****?** … No / Yes
Yes
✔ **Would you like to use** **`src/` directory****?** … No / Yes
Yes
✔ **Would you like to use** **App Router****? (recommended)** … No / Yes
Yes
✔ **Would you like to customize the default** **import alias** **(@/*)?** … No / No
.reduce()
You should know the basics of JavaScript like what an object is, what functions are and what the .reduce()
array method is.
As a refresher, the .reduce()
method in JavaScript processes each element in an array. It combines them into a single output value. The method takes a function as an argument. This function is called the reducer
function and applied to each element in the array.
Here's a basic example using .reduce()
to sum up an array of numbers:
const numbers = [1, 2, 3, 4];
const sum = numbers.reduce((total, current) => total + current, 0);
console.log(sum); // Output: 10
Every reducer functions takes in two parameters: the accumulator in this case called total
and the current value.
If that was too fast or too much for you, read "Unleash JavaScript's Potential with Functional Programming" that explains all of these things in-depth. It also prepares you for some advanced selector composition, which you will learn later in this article.
Now let's break down all of the building blocks of a Redux. Starting with ...
Actions are the only way your application communicates with the store in Redux.
const actionWithoutPayload = { type: "some-string" };
const actionWithPayload = {
type: 'some-string',
payload: { message: "I can be anything. In this case, I'm an object." },
};
Actions are plain JavaScript objects and must have a type
property. The type
indicates the action's purpose.
You can also pass additional data using the payload
property. A payload
can be any serializable data type in JavaScript.
const saveUserAction = {
type: "saveUser",
payload: { id: 1, name: "Jan Hesters", job: "mentor" },
};
saveUserAction
shows you a more real-world example. You might see an action like this when someone saves some entity, in this case a user. It contains the user to be saved as the payload.
The second building block are reducers.
Reducers are pure functions in Redux that take the current state and an action, and return a new state.
Create a file in src/app/example-reducer.js
.
const reducer = (state, action) => {
switch (action.type) {
case "INCREMENT": {
return state + 1;
}
case "INCREMENT_BY": {
return state + action.payload;
}
default:
return state;
}
}
state
is the accumulator, and action is the current value. When you write reducers, you usually use a switch
statement which evaluates the action's type
. In this example, the reducer
function handles two action types:
"INCREMENT"
increases the state by one."INCREMENT_BY"
increases the state by the amount specified in the action's payload
.The default
case returns the current state unchanged, which is important for handling any unknown actions.
Remember, reducers must be pure functions - that means:
Date.now()
or Math.random()
.And make sure that each case uses the return
statement to prevent any case from falling through.
const reducer = (state, action) => {
switch (action.type) {
case "INCREMENT": {
return state + 1;
}
case "INCREMENT_BY": {
return state + action.payload;
}
default:
return state;
}
}
const actions = [
{ type: "INCREMENT" },
{ type: "INCREMENT_BY", payload: 8958 },
{ type: "INCREMENT_BY", payload: 41 },
{ type: "INCREMENT" }
];
const state = actions.reduce(reducer, 0);
console.log('state', state); // state 9001
To use your reducer
you can create an array of actions, which lists the actions to process. Notice how only the 'INCREMENT_BY'
action has a payload.
Now you can reduce over your actions, passing in your reducer
as the reducer function and 0
as the initial value for the state
. The result is 9001. This usage example visualizes where the reducer
part of the name in Redux comes from.
You can refactor this code to be more clean.
const initialState = 0;
const reducer = (state = initialState, action) => {
switch (action.type) {
case "INCREMENT": {
return state + 1;
}
case "INCREMENT_BY": {
return state + action.payload;
}
case "RESET": {
return initialState;
}
default:
return state;
}
}
const actions = [
{ type: "INCREMENT" },
{ type: "INCREMENT_BY", payload: 8958 },
{ type: "RESET" },
{ type: "INCREMENT_BY", payload: 41 },
{ type: "INCREMENT" }
];
const state = actions.reduce(reducer, initialState);
console.log('state', state); // state 42
Capture the initial state in its own variable to improve the code.
This lets you easily use default parameters for the initial state and add a new action to reset the reducer to its initial state.
Each case of the reducer must return data with the same type and shape as the initial state. If the initial state is a number, each case must return a number. If the initial state is an object with specific properties, each case must return an object of the same shape.
You can test it, by adding it to your actions array. And instead of hardcoding 0, you can pass the initial state to the reduce
method. This prevents errors if you ever change the initial state. Now the result is 42.
But you can improve the code even more.
const increment = () => ({ type: "INCREMENT" });
const incrementBy = payload => ({ type: "INCREMENT_BY", payload });
const reset = () => ({ type: "RESET" });
const initialState = 0;
const reducer = (state = initialState, action) => {
switch (action.type) {
case "INCREMENT": {
return state + 1;
}
case "INCREMENT_BY": {
return state + action.payload;
}
case "RESET": {
return initialState;
}
default:
return state;
}
}
const actions = [
increment(),
incrementBy(8958),
reset(),
incrementBy(41),
increment()
];
const state = actions.reduce(reducer, initialState);
console.log('state', state); // state 42
Create factory functions for your actions. In Redux, these are known as action creators. If an action needs a payload, pass it as a parameter to the action creator.
Replace the hardcoded actions in your array with calls to these action creators. You pass the numbers as arguments to the incrementBy
action creators that need a payload.
After these refactors, the resulting state
should remain the same.
Action creators have several benefits:
// Default parameters
const addNeighbor = ({ fullName = "N/A", joinDate = new Date() } = {}) => ({
type: "addNeighbor",
payload: { fullName, joinDate }
});
const neighbor = addNeighbor().payload;
// Mapping values
const fetchedUser = ({ firstName, lastName }) => ({
type: 'fetchedUser',
payload: { name: `${firstName} ${lastName}` },
});
Can you clean this code up even more?
Yes you can. Now you'll learn some senior secrets on structuring your Redux code.
export const increment = () => ({ type: 'INCREMENT' });
export const incrementBy = payload => ({ type: 'INCREMENT_BY', payload });
export const reset = () => ({ type: 'RESET' });
export const slice = 'example';
const initialState = { count: 0 };
export const reducer = (state = initialState, { type, payload } = {}) => {
switch (type) {
case increment().type: {
return { ...state, count: state.count + 1 };
}
case incrementBy().type: {
return { ...state, count: state.count + payload };
}
case reset().type: {
return initialState;
}
default: {
return state;
}
}
};
const actions = [
increment(),
incrementBy(8958),
reset(),
incrementBy(41),
increment(),
];
const state = actions.reduce(reducer, reducer());
console.log('state', state); // state { count: 42 }
First of all, create a new variable called slice
. The slice
variable is a misnomer. A better name would be sliceName
or substateName
.
A slice usually refers to the substate of the root state, like slice of a pie. Each slice is responsible for a distinct feature or domain within your app. It includes its own reducer, action creators, and selectors. In this case, the slice is the example sub state. You're going to learn exactly how the slice
variable will be used later in this article. This is nothing senior, yet, but a simple step that prepares your code for correct usage later in your app.
Next, destructure the type
and payload
of your action in your reducer and default it to the empty object. Destructuring reduces the amount of code you need to write in your switch
statement.
Also tightly couple your action creators directly to your switch cases by using the action creators' .type
property instead of hardcoding strings. This approach prevents typos in action types and makes refactors easier because changes in your action types automatically propagate to your reducer.
To keep your reducer
a pure function, you create new objects using the spread syntax instead of modifying the current state. Your reducers must always modify state immutably. Keeping your state immutable makes your state updates predictable and helps with efficient rendering updates.
The second reason to use spread is to keep your state shape intact. In each case, you're only updating the count
property. If you add more properties to your state, spreading ensures you avoid overwriting them and only change the key you intend to change.
Your refactor has one final benefit: when you call your reducer without arguments, it now returns the initial state. This makes sure that wherever you import your reducer and need its initial state, you don't need to import the initial state separately, reducing the risk of bugs.
Next, you're going to learn about the third building block: the store. The store is a central place where your application's state lives and reducers and middleware are wired together.
Here is what an example implementation looks like. (Create a new file called src/app/create-store.js
.)
export function createStore(reducer, initialState) {
let state = initialState;
const getState = () => state;
return { getState };
}
Your application's state will be encapsulated in the closure of the createStore
function.
In your createStore
implementation, the getState
function uses closure for encapsulation and data privacy. Since getState
is defined inside createStore
, it has exclusive access to the state
variable, keeping it private and preventing manual mutations.
Then create a new file called src/app/store.js
and import your createStore
function.
import { createStore } from './create-store';
import { reducer } from './example-reducer';
const store = createStore(reducer, reducer());
console.log('state', store.getState()); // state { count: 0 }
Use your createStore
function to create your store. Calling the reducer
without arguments provides the initial state, which is { count: 0 }
in this case. You can use getState()
to access your store's current state.
The Redux package comes with it's own more sophisticated version of the createStore
function, so install the redux
package.
npm i redux
Then import the createStore
function.
import { legacy_createStore as createStore } from 'redux';
import { reducer } from './example-reducer';
const store = createStore(reducer, reducer());
console.log('state', store.getState()); // state { count: 0 }
You're going to use the legacy_createStore
import because the regular createStore
import is deprecated. It will keep working forever, but the maintainers of Redux deprecated it to nudge people to use Redux Toolkit, which you're going to learn in the article of this series.
Now that you got your store, how do you send actions to it to manipulate it's state? You use a function called dispatch
for that.
Go back to your own implementation of the createStore
function and modify it to expose a dispatch
method.
export function createStore(reducer, initialState) {
let state = initialState;
const dispatch = action => {
state = reducer(state, action);
};
const getState = () => state;
return { dispatch, getState };
}
dispatch
takes in an action and uses the reducer
from your createStore
function to calculate the next state, and then mutates the state in the closure.
The store created using the createStore
function from Redux already has a dispatch
method.
import { legacy_createStore as createStore } from 'redux';
import { increment, incrementBy, reducer, reset } from './example-reducer';
const store = createStore(reducer, reducer());
const actions = [
increment(),
incrementBy(5),
reset(),
incrementBy(41),
increment(),
];
for (const action of actions) {
store.dispatch(action);
}
console.log('state', store.getState()); // state { count: 42 }
Define an array of actions using your actions from your example reducer. Then, use a for
loop to dispatch each action to the store. After that, calling getState()
will give you the current state of your store.
You've learned how to set up your store with a single reducer. But as mentioned earlier, Redux apps usually have many slices, each corresponding to a different feature or domain of your app. So, how do you handle multiple reducers in the same store?
To show this, create a user profile reducer in src/app/user-profile-reducer.js
.
export const loginSucceeded = payload => ({ type: 'LOGIN_SUCCEEDED', payload });
export const usersFetched = payload => ({ type: 'USERS_FETCHED', payload });
export const slice = 'userProfile';
const initialState = { currentUserId: null, users: {} };
export const reducer = (state = initialState, { type, payload } = {}) => {
switch (type) {
case loginSucceeded().type: {
return {
...state,
currentUserId: payload.id,
users: { ...state.users, [payload.id]: payload },
};
}
case usersFetched().type: {
const newUsers = { ...state.users };
payload.forEach(user => {
newUsers[user.id] = user;
});
return { ...state, users: newUsers };
}
default: {
return state;
}
}
};
Create two actions: one for when the user logs in and another for when a list of users is fetched.
You also want to normalize your state. This means that when a user is fetched, you're going to save them in an object, where the key is the user's id
, and the value is the entire user object.
A populated state shape for this slice could look like this.
{
"currentUsersId": "abc-123",
"users": {
"abc-123": {
"id": "abc-123",
"email": "johndoe@example.com",
"fullName": "John Doe"
},
"xyz-789": {
"id": "xyz-789",
"email": "janesmith@example.com",
"fullName": "Jane Smith"
}
}
}
A normalized state shape has several advantages over storing users in an array:
Always create a new object when updating the users' object to maintain state immutability.
The payload for the loginSucceeded
action includes the full user profile for the user that just logged in, such as their ID, name, and email. The payload for the usersFetched
action is an array of users.
Notice that the action creators and the types of the actions that they create are named in the past tense - this is a good practice and has two advantages.
First, this naming convention indicates what event just happened, making your app easier to debug. Some developers name their actions like a process, which makes debugging harder because you lack the knowledge of where in your app the action was dispatched.
const setCurrentUser = payload => ({ type: 'SET_CURRENT_USER', payload }); // 🚫 Bad!
const loginSucceeded = payload => ({ type: 'LOGIN_SUCCEEDED', payload }); // ✅ Good!
const changedUser = payload => ({ type: 'CHANGED_USER', payload }); // ✅ Good!
Second, if two actions trigger the same state update but come from different interactions, unique names help you identify what happened, so you know where in your code your need to look for the bug.
// 🚫 Bad! Avoid this 👇 It's only included in this example, so you see the anti-pattern.
const setCurrentUser = payload => ({ type: 'SET_CURRENT_USER', payload });
const loginSucceeded = payload => ({ type: 'LOGIN_SUCCEEDED', payload });
const changedUser = payload => ({ type: 'CHANGED_USER', payload });
const changeCurrentUser = (state, currentUser) => ({
...state,
currentUserId: currentUser.id,
users: { ...state.users, [currentUser.id]: currentUser },
});
export const reducer = (state = initialState, { type, payload } = {}) => {
switch (type) {
case setCurrentUser().type: { // 🚫 Bad! Only shown as an example.
return changeCurrentUser(state, payload);
}
case changedUser().type: {
return changeCurrentUser(state, payload);
}
case loginSucceeded().type: {
return changeCurrentUser(state, payload);
}
// ... rest
}
};
All three actions trigger the same state change because you need to update the current user after they log in, and there might also be an interface that lets the user switch users. Both cases could be handled by the setCurrentUser
action. However, when you see that the setCurrentUser
action has been dispatched, you have no idea what just happened. But, because you gave them their own names, for loginSucceeded
and changeUser
it's immediately obvious what happened.
If multiple actions require the same state update, abstract that away in a helper function and use it in your reducer for multiple cases. In this case, the function is called changeCurrentUser
.
Some developers use fall-through in switch cases to batch updates to handle the same state update. But you want to avoid fall-through switch cases to prevent unwanted bugs.
export const reducer = (state = initialState, { type, payload } = {}) => {
switch (type) {
// fall-through: bad 🚫
case setCurrentUser().type:
case changedUser().type:
// Imagine adding a case here with a new return statement. It would suddenly
// break the intended behavior of `setCurrentUser` and `changedUser`.
case loginSucceeded().type: {
return {
...state,
currentUserId: currentUser.id,
users: { ...state.users, [currentUser.id]: currentUser },
}
}
// ... rest
}
};
The patterns for handling switch statements and action handlers are just one example of how learning Redux promotes clean code patterns that can be applied to many coding scenarios beyond Redux.
Now in your store, import each reducer's slice alongside the respective reducer.
import { legacy_createStore as createStore } from 'redux';
// Use `as` to avoid naming conflicts.
import { reducer as exampleReducer, slice as exampleSlice } from './example-reducer';
import {
reducer as userProfileReducer,
slice as userProfileSlice,
} from './user-profile-reducer';
function combineReducers(reducers) {
return function rootReducer(state = {}, action = {}) {
return Object.keys(reducers).reduce((nextState, slice) => {
nextState[slice] = reducers[slice](state[slice], action);
return nextState;
}, {});
};
}
const rootReducer = combineReducers({
[exampleSlice]: exampleReducer,
[userProfileSlice]: userProfileReducer,
});
const store = createStore(rootReducer, rootReducer());
console.log('state', store.getState());
// state {
// example: { count: 0 },
// userProfile: { currentUserId: null, users: {} }
// }
Then create a combineReducer
s function. The function merges several smaller reducers into a single reducer that manages the overall application state, usually called the "root reducer". Each reducer passed to combineReducers
handles its own part of the state, which is defined by their respective slice key.
When an action is dispatched, combineReducers
calls each reducer with its current slice of the state and the action, then merges the results into a new state object.
Use your combineReducers
function to create a root reducer and modify your store to use that root reducer.
When you call getState()
, you can see each slice and its respective state in the store's current state.
Instead of creating your own combineReducers
function, use the combineReducers
function from the Redux package.
import { combineReducers, legacy_createStore as createStore } from 'redux';
import { reducer as exampleReducer, slice as exampleSlice } from './example-reducer';
import {
reducer as userProfileReducer,
slice as userProfileSlice,
} from './user-profile-reducer';
const rootReducer = combineReducers({
[exampleSlice]: exampleReducer,
[userProfileSlice]: userProfileReducer,
});
const store = createStore(rootReducer, rootReducer());
console.log('state', store.getState());
// state {
// example: { count: 0 },
// userProfile: { currentUserId: null, users: {} }
// }
Instead of interacting directly with your store from your UI code, Redux is commonly used with "UI binding" libraries.
For React there is React Redux, which is the official library maintained by the Redux team. React Redux has built-in performance optimizations to ensure your component only re-renders when necessary. Install it in your project.
npm i react-redux
In your src/app/store.js
, create a makeStore
function and get rid of your store
object.
import { combineReducers, legacy_createStore as createStore } from 'redux';
import { reducer as exampleReducer, slice as exampleSlice } from './example-reducer';
import {
reducer as userProfileReducer,
slice as userProfileSlice,
} from './user-profile-reducer';
const rootReducer = combineReducers({
[exampleSlice]: exampleReducer,
[userProfileSlice]: userProfileReducer,
});
export const makeStore = () => {
return createStore(rootReducer, rootReducer());
};
A Next.js server can handle multiple requests at once, so you need to create a new Redux store for each request and avoid sharing the store across requests from different clients. That is why you create the makeStore
function instead of defining your store
as a global variable. If you're NOT using Next.js with the app/
directory, but are working on a regular Single Page Application (SPA), you can safely use a global store
variable. But in Next.js this would cause problems because every user would get the same store reference.
Create a provider for your store in src/app/store-provider.js
and use the makeStore()
function within it.
'use client';
import { useRef } from 'react';
import { Provider } from 'react-redux';
import { makeStore } from './store';
export function StoreProvider({ children }) {
const storeRef = useRef();
if (!storeRef.current) {
storeRef.current = makeStore();
}
return <Provider store={storeRef.current}>{children}</Provider>;
}
Import the Provider
from React Redux and your makeStore
function.
Use a ref
to ensure that the store is only created once. Although the component renders only once per server request, it might re-render multiple times on the client if stateful components are higher in the tree or if the component has mutable state that triggers re-renders. When it re-renders, you prevent a new store from being created through the if
statement.
Pass the ref
of your store to the Provider
from React Redux.
You want to use your StoreProvider
anywhere in the component tree above where the store is used. In this tutorial, you're going put your provider on the root layout to make Redux available on every page. This make the Redux store available to your whole app via the React context API.
Modify your root layout file in src/app/layout.js
.
import './globals.css';
import { Inter } from 'next/font/google';
import { StoreProvider } from './store-provider';
const inter = Inter({ subsets: ['latin'] });
export const metadata = {
title: 'Jan Hesters Redux Tutorial',
description: 'Part one of five to master Redux.',
};
export default function RootLayout({ children }) {
return (
<StoreProvider>
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
</StoreProvider>
);
}
This makes the Redux context available to your whole app. If you only want to use Redux on certain routes, you can use the StoreProvider
on the respective page or route layout.
How do you get data from the store to your app? That's where selectors come in.
Selectors are pure functions that take in your Redux state and return a specific part of it or an aggregated value.
There is no need to code along for the following examples. I'll let you know, when it's time to code along again.
import { combineReducers, legacy_createStore as createStore } from 'redux';
import {
incrementBy,
reducer as exampleReducer,
slice as exampleSlice,
} from './example-reducer';
import {
fetchedUsers,
loginSuccess,
reducer as userProfileReducer,
slice as userProfileSlice,
} from './user-profile-reducer';
const rootReducer = combineReducers({
[exampleSlice]: exampleReducer,
[userProfileSlice]: userProfileReducer,
});
export const makeStore = () => {
return createStore(rootReducer, rootReducer());
};
const store = makeStore();
store.dispatch(incrementBy(10));
store.dispatch(
loginSuccess({
id: 'user123',
email: 'johndoe@example.com',
firstName: 'John',
lastName: 'Doe',
}),
);
store.dispatch(
fetchedUsers([
{
id: 'user123',
email: 'johndoe@example.com',
firstName: 'John',
lastName: 'Doe',
},
{
id: 'user456',
email: 'janesmith@example.com',
firstName: 'Jane',
lastName: 'Smith',
},
]),
);
console.log('state', store.getState());
// state {
// "example": {
// "count": 10
// },
// "userProfile": {
// "currentUserId": "user123",
// "users": {
// "user123": {
// "id": "user123",
// "email": "johndoe@example.com",
// "firstName": "John",
// "lastName": "Doe",
// },
// "user456": {
// "id": "user456",
// "email": "janesmith@example.com",
// "firstName": "Jane",
// "lastName": "Smith",
// }
// }
// }
// }
const selectCurrentCount = state => state.example.count;
const currentCount = selectCurrentCount(store.getState());
console.log('currentCount', currentCount); // 10
Remember, after merging your reducers into one using combineReducers
, you can dispatch actions to change your state.
The code example above shows how to dispatch actions to create a state with a count of 10 and two users in the user profile slice. One user is the current user, who in a real-world app would be logged in through the browser.
You can write a selectCurrentCount
selector that takes in the state and returns the current count.
And if you wanted to grab the current user's email you can create a selector for that, too.
// ... existing code
const selectCurrentUsersEmail = state =>
state.userProfile.users[state.userProfile.currentUserId]?.email ?? '';
const currentUserEmail = selectCurrentUsersEmail(store.getState());
console.log('currentUserEmail', currentUserEmail); // johndoe@example.com
You can use the normalized state to easily access the current user object and retrieve the email using optional property access. It's best practice to ensure your selector always returns the same data type with meaningful default values. If the user is undefined
, the optional property access returns undefined
, and the nullish coalescing operator (??
) will return an empty string. This ensures your selector always returns a string.
Selectors can also take arguments and aggregate values, allowing them to compute new data from the state, filter it, or transform it.
Here's an example of a selector that both accepts a user ID as a parameter and derives the user's full name by combining their first and last names.
// ... existing code
const selectFullNameById = (state, userId) => {
const user = state.userProfile.users[userId];
return user ? `${user.firstName} ${user.lastName}` : 'User not found';
};
const user456FullName = selectFullNameById(store.getState(), 'user456');
console.log('user456FullName', user456FullName); // Jane Smith
const notFound = selectFullNameById(store.getState(), 'user789');
console.log('notFound', notFound); // User not found
If no user is found, it returns 'User not found'
, providing a meaningful default value. If you try to render a non-existent user’s email, it will display "User not found" instead, which offers a better user experience than showing nothing or causing your app to crash.
There is more to learn about selectors, but the best way to understand them is in context. To do that, you need to see how React components connect to Redux.
React Redux gives you two APIs to link your components to Redux. You can either use hooks or a HOC.
You're first going to see the hooks API.
Now you can code along again.
Create a file src/app/hooks.js
which contains the Redux hooks.
import { useDispatch, useSelector, useStore } from 'react-redux';
export const useAppDispatch = useDispatch.withTypes();
export const useAppSelector = useSelector.withTypes();
export const useAppStore = useStore.withTypes();
If you want to use Redux' hooks, use these throughout your app instead of plain the useDispatch
and useSelector
hooks because these hooks have better type-safety.
Modify your main page.js
component to get the count from your state.
'use client';
import { useAppSelector } from './hooks';
export default function Home() {
const count = useAppSelector(state => state.example.count);
return (
<main className="flex min-h-screen flex-col items-center p-24">
<h1 className="text-4xl font-bold">Count from the Example Slice</h1>
<div className="flex items-center justify-center space-x-4">
<p className="text-2xl">Count: {count}</p>
</div>
</main>
);
}
You'll have to use the 'use client'
directive here, too, because only client components have access to the Redux context.
Import the useAppSelector
hook and inline the selector function to get the current count.
Remember, you wrapped your root layout with the Redux provider earlier. This gives all components in the component tree access to the Redux context, which holds the store, dispatch
, and state. The useAppSelector
hook has access to this context and useAppSelector
takes in a selector as a callback. It then passes the state to the selector as its first argument. Keep in mind, since React Server Components cannot use hooks or context, you can't read or write from the Redux store within RSCs.
Now whenever the returned value from useAppSelector
changes, the Home
component will re-render.
However, it's important to understand that useAppSelector
automatically memoizes its value. That means it only triggers a re-render if the selector's result is different than the last result based on a strict equality comparison (===
) . You can check out "useCallback vs. useMemo" for an in-depth breakdown of how memoization works in React. useAppSelector
uses useCallback
behind the scenes to "talk to React" to trigger the re-render.
useAppSelector
takes in a selector as its callback function.
Remember, a selector is a pure function that takes in the global state and returns an aggregated or derived value.
Define a selectCurrentCount
selector in your src/app/example-reducer.js
below your reducer
.
// ... existing code
export const selectCurrentCount = state => state[slice].count;
Saving selectors in variables is better than inlining them. This way, if you use the same selector in multiple components, and you need to change it, you only need to update it once, instead of updating each inline callback in the components.
Now use it your Home
component and replace your inlined function with the selector.
'use client';
import { selectCurrentCount } from './example-reducer';
import { useAppSelector } from './hooks';
export default function Home() {
const count = useAppSelector(selectCurrentCount);
return (
<main className="flex min-h-screen flex-col items-center p-24">
<h1 className="text-4xl font-bold">Redux Basics</h1>
<div className="flex items-center justify-center space-x-4">
<p className="text-2xl">Count: {count}</p>
</div>
</main>
);
}
You will learn more about the benefits of defining selectors as functions instead of inlining them soon.
Before that, you also need to modify your state. For that you need the dispatch
function that you learned about earlier. You can get access to it inside of your React component by using the useAppDispatch
function.
'use client';
import { increment, selectCurrentCount } from './example-reducer';
import { useAppDispatch, useAppSelector } from './hooks';
export default function Home() {
const count = useAppSelector(selectCurrentCount);
const dispatch = useAppDispatch(); // This is store.dispatch.
return (
<main className="flex min-h-screen flex-col items-center p-24">
<h1 className="text-4xl font-bold">Redux Basics</h1>
<div className="flex items-center justify-center space-x-4">
<p className="text-2xl">Count: {count}</p>
<button
className="bg-white text-black hover:bg-white/90 inline-flex h-9 items-center justify-center whitespace-nowrap rounded-md px-4 py-2 text-sm font-medium shadow transition-colors"
onClick={() => {
dispatch(increment());
}}
>
Increment
</button>
</div>
</main>
);
}
The useAppDispatch
hook returns the store.dispatch
method, accessing it through the Redux context provided by your StoreProvider
. Then you can dispatch your action creator. In this example, you're going to dispatch the increment action when a user clicks a button. Clicking the button will update the state and then automatically change the count
value that your component receives, which triggers a re-render and updates your UI in the browser.
connect
HOCThe other way to connect your Redux store to your component is with a higher-order component (HOC). A higher-order component is a function that takes in a component and returns a component. If you're unfamiliar with them, read "Higher-Order Components Are Misunderstood In React".
The HOC that React Redux exposes is called connect
. Refactor your page component to remove the selectors and use connect
.
'use client';
import { connect } from 'react-redux';
import { increment, selectCount } from './example-reducer';
// Shadowing: The `increment` function taken in as props is NOT the pure
// action creator imported from './example-reducer'.
// Instead it is a function that automatically dispatches the increment
// action for you.
function Home({ count, increment }) {
return (
<main className="flex min-h-screen flex-col items-center p-24">
<h1 className="text-4xl font-bold">Redux Basics</h1>
<div className="flex items-center justify-center space-x-4">
<p className="text-2xl">Count: {count}</p>
<button
className="bg-white text-black hover:bg-white/90 inline-flex h-9 items-center justify-center whitespace-nowrap rounded-md px-4 py-2 text-sm font-medium shadow transition-colors"
onClick={() => {
// No need to wrap increment in dispatch like this:
// dispatch(increment());
increment();
}}
>
Increment
</button>
</div>
</main>
);
}
const mapStateToProps = state => ({ count: selectCount(state) });
const mapDispatchToProps = { increment };
export default connect(mapStateToProps, mapDispatchToProps)(Home);
First, modify your page component to accept the count
and increment
function as props.
The connect
HOC from React Redux links the Redux store to the React component. It takes in two parameters:
mapStateToProps
is a function that defines how to transform the current Redux store state into component props. In this case, it maps the state to the props of the Home
page component. If the Redux state related to one of the selectors changes it triggers a re-render.mapDispatchToProps
is an object that defines which action creators to pass to the component as props. Notice that you do NOT have to wrap increment
with dispatch
because mapDispatchToProps
does that automatically for you. The increment
function that your component gets passed in through its props is different from the increment
action creator that you import. When two variables have the same name it is called "shadowing". In Redux, with the connect
HOC, shadowing can happen. But don't let this confuse you. The increment
in the props is wrapped with dispatch, so when you call it, the "increment" action is sent to your store.const mergeProps = (stateProps, dispatchProps, ownProps) => ({
...stateProps, // from mapStateToProps
...dispatchProps, // from mapDispatchToProps
...ownProps, // passed in to the component wrapped by connect from its parent
});
connect(mapStateToProps, mapDispatchToProps, mergeProps);
There is also a third parameter for the connect
higher-order component called mergeProps
. It allows you to mix the props from Redux with the props passed to it from parent components. It is rarely used, so it's enough for you to know it exists and if you ever encounter it, you can look it up in the documentation.
Earlier you set up two slices: the example slice and the user profile slice. The first reason to show two slices in this article is to show you combineReducers
. The second is that because the user profile slice is more complex, you can see the benefits of selectors.
Add some new selectors to your src/app/user-profile-reducer.js
.
export const loginSucceeded = payload => ({ type: 'LOGIN_SUCCEEDED', payload });
export const usersFetched = payload => ({ type: 'USERS_FETCHED', payload });
export const slice = 'userProfile';
const initialState = { currentUserId: null, users: {} };
export const reducer = (state = initialState, { type, payload } = {}) => {
switch (type) {
case loginSucceeded().type: {
return {
...state,
currentUserId: payload.id,
users: { ...state.users, [payload.id]: payload },
};
}
case usersFetched().type: {
const newUsers = { ...state.users };
payload.forEach(user => {
newUsers[user.id] = user;
});
return { ...state, users: newUsers };
}
default: {
return state;
}
}
};
/**
* Add your selectors below your reducer.
*/
const selectUserProfileSlice = state => state[slice];
export const selectCurrentUsersId = state => state[slice].currentUserId;
export const selectUsers = state => state[slice].users;
export const selectCurrentUser = state =>
state[slice].users[state[slice].currentUserId];
export const selectCurrentUsersEmail = state =>
state[slice].users[state[slice].currentUserId]?.email ?? '';
export const selectCurrentUsersFullName = state =>
`${state[slice].users[state[slice].currentUserId]?.firstName ?? ''} ${state[slice].users[state[slice].currentUserId]?.lastName ?? ''}`;
export const selectIsLoggedIn = state => Boolean(state[slice].currentUserId);
selectUserProfileSlice
: Retrieves the userProfile
slice of the state.selectCurrentUsersId
: Gets the current user's ID from the userProfile
slice.selectUsers
: Returns the users
object from the userProfile
slice.selectCurrentUser
: Retrieves the complete data of the current user. It takes advantage of the normalized users.selectCurrentUsersEmail
: Returns the email address of the current user.selectCurrentUsersFullName
: Returns the aggregated full name of the current user.selectIsLoggedIn
: Checks if a user is logged in.Now that you've seen a couple of selectors, you can understand their benefits.
A facade is a design pattern where you provide a simplified interface to a complex subsystem.
You have state shape dependencies when your code relies on a specific shape of state. A change in the state's shape can break the dependent code. The shape of your state is defined by it's data type and by its content. For objects, the shape refers to their nesting structure.
Currently, your state looks like this.
{
"userProfile": {
"currentUsersId": "abc-123",
"users": {
"abc-123": {
"id": "abc-123",
"email": "johndoe@example.com",
"firstName": "John",
"lastName": "Doe"
},
"xyz-789": {
"id": "xyz-789",
"email": "janesmith@example.com",
"firstName": "Jane",
"lastName": "Smith"
}
}
},
"counter": {
"count": 0
}
}
Your users are normalized and each user is saved in an object where the key is their ID and the value is the user.
Now imagine that for some reason you would need to refactor your state shape, so that instead of your users being normalized, they're now an array.
{
"userProfile": {
"currentUsersId": "abc-123",
"users": [
{
"id": "abc-123",
"email": "johndoe@example.com",
"firstName": "John",
"lastName": "Doe"
},
{
"id": "xyz-789",
"email": "janesmith@example.com",
"firstName": "Jane",
"lastName": "Smith"
}
]
},
"counter": {
"count": 0
}
}
If you would have inlined your user profile selectors ...
function SomeComponent() {
// selectCurrentUsersEmail selector inlined for normalized state
const email = useAppSelector(state =>
state.userProfile.users[state.userProfile.currentUserId]?.email || ''
);
}
... you'd need to update them in every component to support the array by using the .find
array method.
function SomeComponent() {
// Update the selectCurrentUsersEmail selector to work with
// the new array structure.
const email = useAppSelector(state => {
const users = state.userProfile.users;
const currentUserId = state.userProfile.currentUsersId;
const currentUser = users.find(user => user.id === currentUserId);
return currentUser?.email ?? '';
});
// ...
}
You need to update your code like this everywhere you're using users directly or any derived state, like the current user's email.
This also applies when you save selectors as functions. You'd need to refactor them like this. (No need to code along here. You're not actually going to refactor your state shape. This is still a hypothetical example.)
export const selectCurrentUsersId = state => state[slice].currentUsersId;
export const selectUsers = state => state[slice].users;
export const selectCurrentUser = state =>
state[slice].users.find(user => user.id === state[slice].currentUsersId);
export const selectCurrentUsersEmail = state =>
state[slice].users.find(user => user.id === state[slice].currentUsersId)?.email ?? '';
export const selectCurrentUsersFullName = state => {
const currentUser = state[slice].users.find(
user => user.id === state[slice].currentUsersId
);
return `${currentUser?.firstName ?? ''} ${currentUser?.lastName ?? ''}`;
};
export const selectIsLoggedIn = state => Boolean(state[slice].currentUsersId);
After this state change you'd need to update the three selectors that access the current user:
selectCurrentUser
,selectCurrentUsersEmail
, andselectCurrentUsersFullName
.All of these selectors now need to be changed to use find
.
There was also a hidden change. selectUsers
now returns an array instead of an object. But, arguably this was the only intentional change. All other selectors should still return the same but had to be refactored to so.
This shows you that the code of the selectors violates one of the most important software design principles:
“A small change in requirements should necessitate a correspondingly small change in the software.” — N. D. Birrell, M. A. Ould, "A Practical Handbook for Software Development"
Luckily, selectors have another key property.
Your selectors compose, which is their key feature that allows them to abstract away state shape dependencies.
Assume you're back to your old state shape where you normalized your users. Then you could write your selectors using function composition like this. (You want to code along with this refactor.)
// ... existing code
const selectUserProfileSlice = state => state[slice];
const selectCurrentUsersId = state =>
selectUserProfileSlice(state).currentUserId;
const selectUsers = state => selectUserProfileSlice(state).users;
export const selectCurrentUser = state => {
const currentUserId = selectCurrentUsersId(state);
const users = selectUsers(state);
return users[currentUserId];
};
export const selectCurrentUsersEmail = state =>
selectCurrentUser(state)?.email ?? '';
export const selectCurrentUsersFullName = state => {
const currentUser = selectCurrentUser(state);
return `${currentUser?.firstName ?? ''} ${currentUser?.lastName ?? ''}`;
};
export const selectIsLoggedIn = state => Boolean(selectCurrentUsersId(state));
selectCurrentUsersId
and selectUsers
can both use selectUserProfileSlice
.
selectCurrentUser
can use selectUsers
and selectCurrentUsersId
to easily return the current user.
Now, selectCurrentUsersEmail
and selectCurrentUsersFullName
can both use selectCurrentUser
under the hood.
Finally, your selectIsLoggedIn
selector can also use selectCurrentUsersId
.
As you can see, this way all of your selectors are cleanly abstracted away. They only add what's unique about accessing their part of the state, which is specialization. And simultaneously, they hide what is obvious about accessing their state using other selectors, which is generalization.
Again imagine, you'd need to refactor your users from being normalized to being in an array. (Again, skip this refactor.)
const selectUserProfileSlice = state => state[slice];
const selectCurrentUsersId = state =>
selectUserProfileSlice(state).currentUserId;
const selectUsers = state => selectUserProfileSlice(state).users;
export const selectCurrentUser = state => {
const currentUserId = selectCurrentUsersId(state);
const users = selectUsers(state);
return users.find(user => user.id === currentUserId);
};
export const selectCurrentUsersEmail = state =>
selectCurrentUser(state)?.email ?? '';
export const selectCurrentUsersFullName = state => {
const currentUser = selectCurrentUser(state);
return `${currentUser?.firstName ?? ''} ${currentUser?.lastName ?? ''}`;
};
export const selectIsLoggedIn = state => Boolean(selectCurrentUsersId(state));
You'd only need to change one line in the selectCurrentUser
selector. All other selectors could stay the same.
And that is what is meant with the point that selectors abstract away state shape dependencies because they compose. If your state shape changes, you usually only need to adjust the selectors corresponding directly to your shape change. In contrast, un-composed selectors require changes in many places, increasing the chances of bugs.
If you understand memoizing, it should be clear that selectors, as pure functions, can be memoized. If you're unfamiliar with memoizing, resume reading this article after you've read "What Is Memoization? (In JavaScript & TypeScript)" which explains memoization in depth.
To learn how your can memoize your selectors, read the third article in this Redux series. It covers the createSelector
API from Redux Toolkit, which you can use to memoize your selectors.
The goal of this article is to provide you with a senior-level understanding of Redux. So let's raise the code quality level further.
You can clean up your selectors even more using functional programming.
Note: If you're new to functional programming, either read "Unleash JavaScript's Potential with Functional Programming" before continuing, or skip this section and jump to "Display- / container component Pattern" below.
While reading this refactor, you might want to open this article in a new tab and look at the refactor side-by-side compared with its previous version without functional programming.
// ... actions & reducer
const prop = key => obj => obj[key];
const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);
const converge = (merger, fns) => x => merger(...fns.map(fn => fn(x)));
const propOr = defaultValue => key => obj => obj[key] ?? defaultValue;
const selectUserProfileSlice = prop(slice);
const selectCurrentUsersId = pipe(selectUserProfileSlice, prop('currentUserId'));
const selectUsers = pipe(selectUserProfileSlice, prop('users'));
export const selectCurrentUser = converge(prop, [
selectCurrentUsersId,
selectUsers,
]);
export const selectCurrentUsersEmail = pipe(
selectCurrentUser,
propOr('', 'email'),
);
export const selectCurrentUsersFullName = pipe(
selectCurrentUser,
converge(
(firstName, lastName) => `${firstName} ${lastName}`,
[propOr('', 'firstName'), propOr('', 'lastName')],
),
);
export const selectIsLoggedIn = pipe(selectCurrentUsersId, Boolean);
You're going to use four functions.
prop
: Retrieves the specified property from an object. This is useful for accessing specific slices of the state directly.pipe
: Combines multiple functions into a single function that executes from left to right, passing the return value of each function to the next.converge
: Accepts several selectors (or transformations) and a combining function. The selectors gather data from the state, and the combining function merges these pieces of data.propOr
: Similar to prop
, but it provides a default value if the specified property is missing in the object.All of these functions are curried and you use them with partial applications.
Use these helper functions to refactor each of the selectors.
Your selectors still behave the exact same way, but are much cleaner and declarative. Over time reading and writing declarative code will become more intuitive than the old, imperative way.
All helper functions are available in Ramda, so install it.
npm i ramda
Delete your custom helpers and Import the functions from Ramda instead.
import { converge, pipe, prop, propOr } from 'ramda';
// ... rest of your reducers & selectors
You can refactor the selectors of the counter
slice, too.
// ... existing code
const selectExampleSlice = prop(slice);
const selectCount = pipe(selectExampleSlice, prop('count'));
Here, you also use prop
to access the different slices and pipe
to compose your selectors.
The connect
HOC works well with the display- / container component pattern. If you're unfamiliar with it, this pattern organizes components into two categories:
connect
HOC.Create a display component at src/app/user-profile-component.js
.
export const UserProfileComponent = ({ isLoggedIn, email, onLoginClicked }) => (
<div className="flex items-center space-x-4">
{isLoggedIn ? (
<p className="text-2xl">Email: {email}</p>
) : (
<button
className="inline-flex h-9 items-center justify-center whitespace-nowrap rounded-md bg-white px-4 py-2 text-sm font-medium text-black shadow transition-colors hover:bg-white/90"
onClick={() =>
onLoginClicked({ id: '123', email: 'jan@reactsquad.io' })
}
>
Login
</button>
)}
</div>
);
The component takes in a isLoggedIn
boolean and renders the email for the logged in user, or a button that when clicked logs the user in.
Using this display component, you can combine your user profile action creators and selectors using the connect
HOC in a container component.
'use client';
import { connect } from 'react-redux';
import {
loginSucceeded,
selectCurrentUsersEmail,
selectIsLoggedIn,
} from './user-profile-reducer';
import { UserProfileComponent } from './user-profile-component';
const mapStateToProps = state => ({
email: selectCurrentUsersEmail(state),
isLoggedIn: selectIsLoggedIn(state),
});
const mapDispatchToProps = { onLoginClicked: loginSucceeded };
export default connect(
mapStateToProps,
mapDispatchToProps,
)(UserProfileComponent);
The connect
function connects the Redux store with the UserProfileComponent
. It uses mapStateToProps
to subscribe to Redux store updates via selectors and map state to component props. It uses mapDispatchToProps
to bind action creators to the Redux store dispatch, allowing the component to trigger actions.
Notice how this file src/app/user-profile-container.js
now doesn't contain any JSX.
Now import the UserProfileContainer
and render it in your Home
component.
'use client';
import { increment, selectCount } from './example-reducer';
import { useAppDispatch, useAppSelector } from './hooks';
import UserProfileContainer from './user-profile-container';
export default function Home() {
const count = useAppSelector(selectCount);
const dispatch = useAppDispatch();
return (
<main className="flex min-h-screen flex-col items-center p-24">
<h1 className="text-4xl font-bold">Redux Basics</h1>
<div className="flex items-center justify-center space-x-4">
<p className="text-2xl">Count: {count}</p>
<button
className="inline-flex h-9 items-center justify-center whitespace-nowrap rounded-md bg-white px-4 py-2 text-sm font-medium text-black shadow transition-colors hover:bg-white/90"
onClick={() => {
dispatch(increment());
}}
>
Increment
</button>
</div>
<UserProfileContainer />
</main>
);
}
If you run your app now, you can "log in" and see the email displayed in the UI.
Warning: There will be a spike in difficulty here, but relax because this article will break down every step for you.
Now you're going to learn the last building block: middleware.
You're going to see two use cases that are commonly handled through middleware: logging and fetching data.
Imagine you want to debug your Redux app and log out the actions that you dispatch and the state after the action has been handled.
export function dispatchAndLog(store, action) {
console.log('dispatching', action);
store.dispatch(action);
console.log('next state', store.getState());
}
dispatchAndLog(store, incrementBy(42));
You could create a dispatchAndLog
function that logs the action before dispatching it and then logs the resulting state.
import { incrementBy } from './example-reducer';
import { useAppStore } from './hooks';
import { dispatchAndLog } from './temp-wrappers';
function SomeComponent() {
const store = useAppStore();
return (
<button
onClick={() => {
dispatchAndLog(store, incrementBy(42));
}}
>
Click Me
</button>
);
}
You can import it in some component and call it by passing in your store and an action.
When you run your app and click the button, it logs out the action and the state after your action has been handled.
dispatching { type: 'INCREMENT_BY', payload: 42 }
next state { example: { count: 42 }, userProfile: { currentUserId: null, users: {} } }
Now to the second use case, which is fetching data. One of the most common ways to handle the fetching of data in Redux are "thunks".
A thunk is essentially a subroutine used to inject an additional calculation into another subroutine. It delays the calculation of a value until it is needed and can also insert operations at the point of execution.
// calculation of x is immediate
const truth = 21 + 21; // 42
// thunk: calculation of x is delayed
const getTruth = () => 21 + 21;
The term "thunk" was coined after its inventors realized during a late-night discussion that some computational aspects could be precomputed, or "already [have] been thought of." They humorously named it "thunk", joking it was the past tense of "think" at two in the morning.
You’ve learned that actions are objects. But with middleware, actions can become more. Using thunk middleware, actions can also be functions. Instead of dispatching an action object directly, you dispatch a function - the thunk - that can perform asynchronous tasks and then dispatch further actions based on the outcome.
function fetchDataThunk() {
// `getState` is never used by this particular thunk, but all
// thunks have access to both `dispatch` and `getState`.
return async (dispatch, getState) => {
dispatch({ type: 'data/fetchStart' });
try {
const response = await fetch('/api/data');
const data = await response.json();
dispatch({ type: 'data/fetchSuccess', payload: data });
} catch (error) {
dispatch({ type: 'data/fetchFailure', error });
}
};
}
When an action is a function, you call it a "thunk." In Redux, a thunk is a higher-order function that returns another function. This returned function accepts the dispatch
function as its first parameter and the getState
function as the second, and returns a promise that resolves with nothing. It gets access to those arguments from a wrapper, which you will see in a second.
You can create a fetchDataThunk
to handle asynchronous API calls. It dispatches different actions depending on whether the fetch has started, succeeded, or failed.
Here's how you might write a manual dispatchAsync
wrapper to handle thunks.
export function dispatchAsync(store, actionOrThunk) {
if (typeof actionOrThunk === 'function') {
// If it's a function, call it with dispatch and getState.
return actionOrThunk(store.dispatch, store.getState);
} else {
// If it's an action object, dispatch it directly.
return store.dispatch(actionOrThunk);
}
}
This dispatchAsync
function checks if the provided argument is a function. If it is, it assumes it's a thunk and calls it with the dispatch
and getState
methods from the store. Otherwise, it dispatches the action object as usual.
You can use dispatchAsync
in your component to handle this thunk.
import { useAppStore } from './hooks';
import { fetchData } from './temp-thunk-example';
import { dispatchAsync } from './temp-wrappers';
function SomeComponent() {
const store = useAppStore();
return (
<button
onClick={() => {
dispatchAsync(store, fetchData());
}}
>
Fetch Data
</button>
);
}
You simply wrap the dispatching of the fetchData()
thunk with dispatchAsync
.
Now, when the "Fetch Data" button is clicked, dispatchAsync
checks if fetchData()
is a function (which it is) and then calls it with the store's dispatch
and getState
methods. The thunk can then perform the asynchronous fetch operation and dispatch the appropriate actions based on the result.
To prove that this works, you can compose both of your wrappers.
import { useAppStore } from './hooks';
import { fetchDataThunk } from './temp-thunk-example';
import { dispatchAndLog, dispatchAsync } from './temp-wrappers';
function SomeComponent() {
const store = useAppStore();
return (
<button
onClick={() => {
// Compose the two wrappers so that actions dispatched by the thunk get
// logged out.
dispatchAsync(store, (dispatch, getState) => {
// Wrap the dispatch function with dispatchAndLog.
const dispatchWithLog = action => dispatchAndLog(store, action);
// Call the thunk with the wrapped dispatch.
return fetchDataThunk()(dispatchWithLog, getState);
});
}}
>
Fetch Data
</button>
);
}
Inside the onClick
handler, you call dispatchAsync
. Instead of passing the thunk directly, we pass a function that takes dispatch
and getState
. You create a dispatchWithLog
function that wraps the store's dispatch with dispatchAndLog
. You then return the fetchDataThunk
, which is immediately invoked and returns its async inner function. This inner function is then being called with dispatchWithLog
and getState
. By doing this, every action dispatched within the thunk will go through dispatchAndLog
, which logs the action and the next state.
Here is what gets logged out now when you run your app and click "Fetch Data" and the fetch succeeds.
dispatching { type: 'data/fetchStart' }
next state { example: { count: 0 }, userProfile: { currentUserId: null, users: {} } }
dispatching { type: 'data/fetchSuccess', payload: { some: 'data' } }
next state { example: { count: 0 }, userProfile: { currentUserId: null, users: {} } }
If it fails, the following would be logged out.
dispatching { type: 'data/fetchStart' }
next state { example: { count: 0 }, userProfile: { currentUserId: null, users: {} } }
dispatching { type: 'data/fetchFailure', error: { message: 'Error message' } }
next state { example: { count: 0 }, userProfile: { currentUserId: null, users: {} } }
You can imagine that importing these wrapper functions in every component and composing all the wrappers where you use dispatch
is inconvenient. And you'd need a new wrapper function for every new requirement.
Middleware offers an easier solution. Middleware allows you to intercept actions after you dispatched them, but before they reach your reducers.
const someMiddleware = store => next => action => {
// ... the middleware logic, which returns `next(action)`.
}
Middleware in Redux are curried functions that first take a store
object, then a dispatch
function (called next
), and finally the current action
. Middleware returns a function that takes an action and optionally returns a value. This value is usually the next middleware.
A logger middleware that solves the previously mentioned use case looks like this.
const logger = store => next => action => {
console.log('dispatching', action);
let result = next(action);
console.log('next state', store.getState());
return result;
};
But there are many more middleware.
To understand the logger middleware and others, you first need to learn about Redux’s applyMiddleware
function. applyMiddleware
is essential for running middleware. When you provide the middleware to your store, there has to be one applyMiddleware
function wrapped around all of your middleware. The best way to understand applyMiddleware
is by coding your own version from scratch.
Create a function called applyMiddleware
, then update your custom createStore
function to work with middleware.
(Note: The following code example is absolutely self-contained for demonstration purposes, allowing you to focus on the new applyMiddleware
and the modified createStore
function. In other words, you don't need to code along. But you might want to open this article again in a new tab, so you can read the code and the explanation below side by side.)
function applyMiddleware(...middlewares) {
return function enhancer(createStore, reducer, initialState) {
const store = createStore(reducer, initialState);
const middlewareAPI = {
getState: store.getState,
dispatch: (action, ...arguments_) => enhancedDispatch(action, ...arguments_),
};
const chain = middlewares.map(middleware => middleware(middlewareAPI));
const enhancedDispatch = chain.reduceRight(
(currentDispatch, currentMiddleware) => currentMiddleware(currentDispatch),
store.dispatch,
);
return { ...store, dispatch: enhancedDispatch };
};
}
export function createStore(reducer, initialState, enhancer) {
if (enhancer) {
return enhancer(createStore, reducer, initialState);
}
let state = initialState;
const dispatch = action => {
state = reducer(state, action);
};
const getState = () => state;
return { dispatch, getState };
}
const logger = store => next => action => {
console.log('dispatching', action);
let result = next(action);
console.log('next state', store.getState());
return result;
};
const thunk = store => next => action => {
if (typeof action === 'function') {
return action(store.dispatch, store.getState);
}
return next(action);
};
const rootReducer = (state = { count: 0 }, { payload, type } = {}) => {
switch (type) {
case 'INCREMENT': {
return { ...state, count: state.count + 1 };
}
case 'DECREMENT': {
return { ...state, count: state.count - 1 };
}
default: {
return state;
}
}
};
const store = createStore(
rootReducer,
rootReducer(),
applyMiddleware(logger, thunk),
);
store.dispatch({ type: 'INCREMENT' });
store.dispatch({ type: 'DECREMENT' });
const incrementThunk = (dispatch, getState) => {
console.log('Current state before async:', getState());
setTimeout(() => {
dispatch({ type: 'INCREMENT' });
console.log('State after async:', getState());
}, 5000);
}
store.dispatch(incrementThunk);
applyMiddleware
is curried and takes in any number of middleware using the spread operator, so you have access to an array of middleware in the function body.
It returns a function called enhancer
that takes in your createStore
function, a reducer
and an initialState
. These arguments will be passed to the enhancer
from createStore
, which you will see in a minute.
It creates a new store using your createStore
function.
Then, it creates a middlewareAPI
object that exposes a getState
method and a dispatch
method, just like the store
object.
Following that, it maps over all middleware and calls them with the middlewareAPI
. Now you have an array of partially applied middleware called chain
. They all have access to the middlewareAPI because it has been supplied as the store
argument. Assuming you call chain
with the logger
and thunk
middleware, then chain looks like this.
const chain = [
// This IIFE is just there to visualize the partial application for you.
(store => (next => action => {
console.log('dispatching', action);
let result = next(action);
console.log('next state', store.getState());
return result;
}))({ /* middlewareAPI */}),
// This is how it really looks like 👇 `store` is the `middlewareAPI`.
next => action => {
if (typeof action === 'function') {
return action(store.dispatch, store.getState);
}
return next(action);
},
];
Note that store
here is the applyMiddleware
object.
The inner function of applyMiddleware
then uses reduceRight
over these partial applications in chain
to compute the latest version of dispatch
called enhancedDispatch
.
Remember, middleware generally takes in a store, the previous dispatch called next
and returns a new dispatch function that takes an action as its argument.
// const middleware = store => previousDispatch => function newDispatch(action) {
const middleware = store => next => action => {
// ...
}
When using multiple middleware, each one takes in a next
parameter, which is the dispatch function modified by the previous middleware in the chain. This lets each middleware wrap or change the dispatch behavior of the middleware before it in the chain
array, or the original store's dispatch if it's the first middleware.
You use reduceRight
because middleware is usually written with the expectation that it will receive the action before any previously registered middleware handles it. This means middleware should be applied in the reverse order of how actions pass through them.
Let's go through this step by step.
1.) Initial dispatch
- It starts with the original dispatch
function from the store.
const dispatch = store.dispatch;
2.) Apply thunk
- The thunk
middleware is the first to wrap around the initial dispatch using reduceRight
with the store
captured in its closure. It checks if the action is a function and, if so, executes it; otherwise, it proceeds.
const dispatch = store.dispatch;
const middleware = next => action => {
if (typeof action === 'function') {
return action(store.dispatch, store.getState);
}
return next(action);
};
3.) Apply logger
- The logger
middleware is then wrapped around the dispatch modified by the thunk
middleware. It logs the action and the state before and after the action is processed.
const dispatch = action => {
if (typeof action === 'function') {
return action(store.dispatch, store.getState);
}
return store.dispatch(action);
};
const middleware = next => action => {
console.log('dispatching', action);
let result = next(action);
console.log('next state', store.getState());
return result;
};
4.) Final enhancedDispatch
- The enhancedDispatch
is now fully composed, incorporating both middleware. First, it logs the action's details, then processes thunks, and finally logs the updated state. The store
remains accessible through the closure from the creation of chain
.
const enhancedDispatch = action => {
console.log('dispatching', action);
let result = (action => {
if (typeof action === 'function') {
return action(store.dispatch, store.getState);
}
return store.dispatch(action);
})(action);
console.log('next state', store.getState());
return result;
};
applyMiddleware
finally returns a new store object with the modified dispatch.
Your createStore
function should now accept a third parameter called enhancer
. If the enhancer
is provided, it returns and calls the enhancer first with the createStore
function itself, then with the reducer
and initialState
. This returns an enhanced version of the store where the dispatch
function has been replaced with the enhancedDispatch
which contains the middleware logic. The rest of createStore
remains unchanged.
Now, let's jump ahead and see how to use your updated createStore
function. You pass applyMiddleware
as the third argument, and applyMiddleware
is called with your middleware. Since applyMiddleware
returns a function with the same signature as createStore
- taking in a reducer
and initialState
, and returning the store - this preserves the original behavior when middleware is applied. If you call the createStore
function inside applyMiddleware
without an enhancer
, it functions normally.
Middleware makes solving the logging problem simple. The logger
middleware logs the current action and calls the next middleware in the chain. After all middleware has processed the action, it logs the store's state and returns the result.
The thunk
middleware expands the capabilities of Redux's dispatch function, allowing you to handle asynchronous operations or complex synchronous logic. If the dispatched action is a function instead of a regular object, the thunk
middleware intercepts it and passes dispatch
and getState
as arguments to that function.
Now define a simple rootReducer
that can handle an INCREMENT
and a DECREMENT
action and then set up the store with your middleware.
Then dispatch some actions as objects and as functions. The function can be an asynchronous function, too.
When you run this example, you get the following output.
$ npx tsx src/app/temp-apply-middleware-example.js
dispatching { type: 'INCREMENT' }
next state { count: 1 }
dispatching { type: 'DECREMENT' }
next state { count: 0 }
Current state before async: { count: 0 }
dispatching { type: 'INCREMENT' }
next state { count: 1 }
State after async: { count: 1 }
The logger function logs out your actions and the state after each action. And you can see that there is a delay before the thunk executes its logic. The logger captures the actions processed by the thunk because both middleware process every action.
The logger and thunk middleware are both available as packages, so install them.
npm i redux-logger redux-thunk
Redux also exports applyMiddleware
. Import it together with the middleware.
import {
applyMiddleware,
combineReducers,
legacy_createStore as createStore,
} from 'redux';
import logger from 'redux-logger';
import { thunk } from 'redux-thunk';
import { reducer as exampleReducer, slice as exampleSlice } from './example-reducer';
import {
reducer as userProfileReducer,
slice as userProfileSlice,
} from './user-profile-reducer';
const rootReducer = combineReducers({
[exampleSlice]: exampleReducer,
[userProfileSlice]: userProfileReducer,
});
export const makeStore = () => {
return createStore(
rootReducer,
rootReducer(),
applyMiddleware(logger, thunk)
);
};
Finally, you can modify your makeStore
function to set up the middleware.
When you now dispatch actions in your app, it logs them out. And you could also dispatch thunks now.
So, let's answer the questions teased at the beginning of this article.
Redux became popular because of the following properties:
To address these issues, server-state libraries like React Query or useSWR became popular. And modern applications built with the newest versions of Next.js or Remix remove the need for global state management. These libraries and frameworks handle state behind the scenes and only expose what you need to build dynamic web apps, making Redux often unnecessary.
But as stated in the beginning of this article, Redux is still used heavily in many applications, so it's tremendously valuable for you to know it.
This article was very theory heavy and in this series, you're going to code two real-world apps using Redux so that you get some hands-on practice.