Redux

Jeff P
23 min readFeb 1, 2024

overview

Redux is quite similar to React useReducer() so if you haven't familiarized yourself with this hook, I’d highly recommend you do that first, as it helps set some of the foundational knowledge for Redux:

Getting Started

The first thing we need to do is actually install redux and react-redux!

npm i redux react-redux

Building the reducer function for redux is very similar to when you build the reducer function with useReducer()

You set some initial state in an object, and then create your reducer function, usually using switch/case statements:

const initialState = {
balance: 0,
loan: 0,
loanPurpose: "",
};

function reducer(state = initialState, action) {
switch (action.type) {
case "account/deposit":
return {
...state,
balance: state.balance + action.payload,
};
case "account/withdraw":
return {
...state,
balance: state.balance - action.payload,
};
case "account/requestLoan":
if (state.loan > 0) return state;
return {
...state,
loan: action.payload.amount,
loanPurpose: action.payload.
};
case "account/payLoan":
return {
...state,
loan: 0,
loanPurpose: "",
balance: state.balance - state.loan,
};

default:
return state;
}
}

A couple of key differences to point out here though.

With Redux you tend to give the state parameter the default value of the initialState state object.

We also give case names as something like account/withdraw or maybe account/requestLoan as this is a kind of convention that comes from “slices”, but we’ll get to that a bit later on.

Also your default case statement in the reducer function returns state rather than throwing an error like we do when using useReducer().

The store

For the purpose of learning, we’ll start with the old way of doing things, which is to create a store (no longer necessary with Redux toolkit!) to see how things work.

We import createStore..

import { createStore } from "redux";

If using VSCode with ESLint, you’ll probably see the word createStore has a line though it like this…

c̵r̵e̵a̵t̵e̵S̵t̵o̵r̵e̵

This is because this method is now deprecated, and very rarely used, however we’ll continue with this method as you’ll often see it when you review older code, plus we’ll eventually get to the more modern way of doing it anyway!

next we’ll create a store using the createStore function we just imported…

const store = createStore(reducer);

We can now use the dispatch() function on our store to pass in a type, and a payload, just like we did with useReducer()

So for example, we could do something like this….

store.dispatch({type: "account/deposit", payload: 500});

You could then check what the current “state” is for the store, by using the getState() method. So for example, if you pushed that to a console log, you’d see something like this…

So to recap before we move on, the dispatch() function, is dispatching to the reducer function the type and payload, and the reducer function is then setting the state accordingly…

const initialState = {
balance: 0,
loan: 0,
loanPurpose: "",
};

function reducer(state = initialState, action) {
switch (action.type) {
case "account/deposit":
return {
...state,
balance: state.balance + action.payload,
};

so the “case” matches for the type “account/deposit”, it then spreads out the existing “state” (which is initialState) and then updates the state.balance value with a new value which is the current value (state.balance) plus the payload value passed in, which in our example above, was 500.

Now we don’t have to just pass in a single value as our payload….we can also pass in an object representing multiple values.

So for example, if we want to update both our “loan” amount and our “loanPurpose” state, then we’d want to pass in TWO values to our reducer. Here’s a quick recap of the reducer for account/requestLoan

case "account/requestLoan":
if (state.loan > 0) return state;
return {
...state,
loan: action.payload.amount,
loanPurpose: action.payload.purpose.
};

So in this case, we can dispatch an object with two values….

store.dispatch(
{
type: "account/requestLoan",
payload:{amount: 500, purpose: "To buy some clothes"}
}
);

so the “loan” value will be updated with the action.payload.amount passed in, and the “loanPurpose” value will be updated with the action.payload.purpose values.

You of course do not need to send any payload at all to the reducer, if you just want it to perform the actions in the reducer. So for example, if we look at the case statement for action/payLoan…

case "account/payLoan":
return {
...state,
loan: 0,
loanPurpose: "",
balance: state.balance - state.loan,
};

As you can see, there’s no actual values that need to be passed in from the dispatch function. All we’re saying is that if this case is called, then spread out the current state, and then set loan to 0, set the loanPurpose to an empty string, and set the balance to whatever the current balance is, minus whatever the state value for “loan” currently is.

So no “payload” is needed…all we need to do is actually trigger this case, and we do this by dispatching an action with no payload…

store.dispatch({type: "account/payLoan"});     // no payload needed

so you can already see, that if this type of dispatch was assinged to a button, then it would do what is in the case statement of the reducer.

Action Creators

Action creators are a convetion that redux developers use. A simple action creator function returns an action object:

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

You then dispatch the action creator…

dispatch(addTodo('Learn more about Redux'));

You would typically create an action creator for each action, so going back to the example, we’ve been using so far, we’d do something like this:

function deposit() {
return {type: "account/deposit", payload: 500}
};


function withdraw() {
return {type: "account/withdraw", payload: 200}
};


function requestLoan() {
return {
type: "account/requestLoan",
payload:{amount: 500, purpose: "To buy some clothes"}
}
};


function payLoan() {
return {type: "account/payLoan"}
};

Now of course one of the benefits of these types of action creators is that we can pass in values as parameters to the functions, rather than hard-code values in like we have done just now. so let’s re-factor these action creators so that we’ll be able to pass in values….

function deposit(amount) {
return {type: "account/deposit", payload: amount}
};

function withdraw(amount) {
return {type: "account/withdraw", payload: amount}
};

function requestLoan(loanAmount, loanPurpose) {
return {
type: "account/requestLoan",
payload:{amount: loanAmount, purpose: loanPurpose}
}
};

function payLoan() {
return {type: "account/payLoan"}
};

so now in terms of dispatching these, we just do this….

store.dispatch(deposit(500));
store.dispatch(withdraw(200));
store.dispatch(requestLoan(500, "to buy clothes"));
store.dispatch(payLoan());

Note: In some older code, you might also see the case statements firstly declared like this:

const ACCOUNT_DEPOSIT = "account/deposit";

and then the statements would be in the reducer like this….

function reducer(state = initialState, action) {
switch (action.type) {
case "ACCOUNT_DEPOSIT":
return {
...state,
balance: state.balance + action.payload,
};

however this is rarely used by modern developers. It’s just something to look out for.

Adding “side effects” into action creators

There may be instances where you want state to be updated as a side effect. You should not do this inside the reducer function.

Reducers Must Be Pure Functions!

A reducer should always return the same output (new state) without producing any side effects.

Reducers must not mutate the existing state or perform any side effects like API calls, changing local storage, or interacting with external systems. They should only compute the new state based on the current state and the action received.

So let’s say we wanted a piece of state inside initialState to be a time/date when the account was created, and we’d get this value by using something like new Date()

We’d use this inside the action creator and NOT the reducer. So for example:

function createCustomer(name, id) {
return {
type: "account/createCustomer",
payload:{customerName: name, customerId: id, createdAt: new Date()}
}
};

So you can see that we’re generating the date at the time this dispatch is called, rather than using new Date() in the reducer itself.

Multiple stores

For the example below, we’ve been using one store, which manages account-related state. We named it “initialState” because it’s just a single object full of account-related information.

But what if we wanted multiple state objects, and multiple reducers?

For example, what if we wanted customerInitialState, AND accountInitialState?

const customerInitialState = {
balance: 0,
loan: 0,
loanPurpose: "",
};

const accountInitialState = {
fullName: "",
nationalId: "",
createdAt: "",
};

We might then have two reducers…

// first reducer
function customerReducer(state = customerInitialState, action) {
switch (action.type) {
case "customer/openAccount":
return {
...state,
fullName: action.payload.customerName,
nationalId: action.payload.customerId,
createdAt: action.payload.createdAt,
};
...
...
...
...
};


// second reducer
function accountReducer(state = accountInitialState, action) {
switch (action.type) {
case "account/deposit":
return {
...state,
balance: state.balance + action.payload,
};
...
...
...
...
};

Now since we’re using the (deprecated) createStore() function, this only takes in a single store by default.

const store = createStore(reducer);

We can’t do something like this, as it won’t work…

const store = createStore(customerReducer, accountReducer);

To fix this we need to use a special function called combineReducers() which allows us to bring multiple reducers together:

const combinedReducers = combineReducers({
account: accountReducer,
customer: customerReducer
});

and now we can pass this into our createStore() function

const store = createStore(combinedReducers);

Using Slices

The modern approach when using redux is to start seperating the code logic into “features” as having multiple reducers and action creators all in a single file becomes tremendously difficult to manage, especially if you had say 10 reducers, 5 action acreators per reducer, etc.

So what we would do is create a “features” folder, and then create sub-folders based on the feature. So for example, we have two reducers, and two pieces of state…. customer and account.

What we’d do is create a customer folder and an account folder inside our features folder, and then in the customer folder we’d create a file called customerSlice.js and inside the account folder we’d create a file called accountSlice.js

Inside each “slice” file, we’d place the associated state, reducers, and action creators.

For example:


// inside accountSlice.js



// initial State
const accountInitialState = {
balance: 0,
loan: 0,
loanPurpose: "",
};


// reducer
export default function accountReducer(state = accountInitialState, action) {
switch (action.type) {
case "account/deposit":
return {
...state,
balance: state.balance + action.payload.amount,
};
case "account/withdraw":
return {
...state,
balance: state.balance - action.payload.amount,
};
case "account/requestLoan":
if (state.loan > 0) return state;
return {
...state,
loan: action.payload.amount,
loanPurpose: action.payload.
};
case "account/payLoan":
return {
...state,
loan: 0,
loanPurpose: "",
balance: state.balance - state.loan,
};

default:
return state;
}
}


// action creators
export function deposit(amount) {
return {type: "account/deposit", payload: amount}
};

export function withdraw(amount) {
return {type: "account/withdraw", payload: amount}
};

export function requestLoan(loanAmount, loanPurpose) {
return {
type: "account/requestLoan",
payload:{amount: loanAmount, purpose: loanPurpose}
}
};

export function payLoan() {
return {type: "account/payLoan"}
};

Note how in the slice, we export the reducer function as “export default” but the action creators are just “named exports”

The reason for doing this is that we will be importing the reducers back to the Redux store, but the actual action creators will be imported elsewhere, where we’ll actually call them.

So in the store.js file, we’d do something like this…

import { createStore, combineReducers } from "redux";

// add in our slices
import accountReducer from "./features/accounts/accountSlice";
import customerReducer from "./features/customers/customerSlice";

const rootReducer = combineReducers({
account: accountReducer,
customer: customerReducer,
});

const store = createStore(rootReducer);

export default store;

Connecting Redux to React!

Up to this point so far, all we’ve done is setup our Redux store, but it’s not yet connected to React. We’ll need to do this to allow us to click buttons or add values to input fields to update stated via our reducers.

It’s worth mentioning at this stage that Redux is NOT React specific. This is what the NPM react-redux package is for! It was not designed purely to cater to React applications, so in order to get it working with React there’s a couple of things we need to do.

Providing the store to the application

The way we provide the store to our application works in a similar way to the way we use the React context api.

Firstly we import the <Provider> component from react-redux, and then import the Redux store we created, into index.js

import { Provider } from "react-redux";
import store from "./store";

We then wrap our entire application with the redux provider, and pass it props of “store” for the redux store

const root = ReactDOM.createRoot(document.getElementById("root"));

root.render(
<React.StrictMode>

// wrap the app inside the react-redux provider and pass in store props
<Provider store={store}>
<App />
</Provider>

</React.StrictMode>
);

By doing this, it means every single component in our application now has access to the store and can dispatch actions to it.

useSelector()

the useSelector() hook is a special react-redux hook which brings in specific parts of the redux store into a component.

So for example, if we want access to the “customer” state and action creators, we’d set it up like this in our component page…

import { useSelector } from "react-redux";


// access customer state and action creators
const customer = useSelector((store) => store.customer);

Now the component is ready to access the entire piece of state for the customer.

Let’s say we wanted to print the customer’s full name, we could do something like this:

 const customerName = useSelector((store) => store.customer.fullName);

return (
<h1>Welcome {customerName}! </h1>
);

Dispatching actions to the store from React components

Ignoring React just for a moment, in redux, we would use the dispatch function to dispatch actions to the store….

 store.dispatch(deposit(500));

However in React, you need to use the react-redux useDispatch() hook to be able to dispatch to the store.

We import the hook in from react-redux, as well as importing any of the named action creators we want to utilize from our slice file…

// hook to dispatch actions to the store
import { useDispatch } from "react-redux";

// action creator we want to use
import { createCustomer } from "./customerSlice";

We then use the action creator as we want to. For example as part of an event handler when a user clicks a button:

  function handleClick() {
if (!fullName || !nationalId) {
return;
}
dispatch(createCustomer(fullName, nationalId));
}

So here we’re saying, if the input fields for name and id are empty, then just return, as the user hasn’t provided the data needed. However if both the name and id fields have been used to update state for fullName and nationalId (which would have been updated by the onChange handlers for them) then go ahead and dispatch to the createCustomer action creator the state for fullName and nationalId.

So it uses this …

export function createCustomer(fullName, nationalId) {
return {
type: "customer/createCustomer",
payload: { fullName, nationalId, createdAt: new Date().toISOString() },
};
}

which matches the case in the customerReducer…

export default function customerReducer(state = initialStateCustomer, action) {
switch (action.type) {
case "customer/createCustomer":
return {
...state,
fullName: action.payload.fullName,
nationalId: action.payload.nationalId,
createdAt: action.payload.createdAt,
};

The old way of connecting Redux to React

We now use the useSelector() and the useDispatch() hooks from react-redux to retrieve state and send state, however there was also an old way of doing this. It’s rarely used nowadays, however you might see it in older code with older versions of react-redux, so it’s worth at least understanding what it was doing.

In older versions (which you can still technically use now, if you really wanted to), you would use the mapStateToProps() function and the connect() function

Here’s an example of the mapStateToProps() function:

function mapStateToProps(state) {
return {
balance: state.account.balance,
};
}

What happens here is that you are passing in the ENTIRE Redux state as a prop, and then it returns an object where keys are prop names and values are slices of the Redux state that the component needs.

Its gets this state from wrapping the entire app with the <Provider> component in index.js

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
);

The Redux store object is more than just the current state. It also includes methods like dispatch and getState, among others. However, mapStateToProps is only concerned with the data part of the store, i.e., the state. This is why we pass in “state” into mapStateToProps, and not “store”.

function mapStateToProps(state) {
return {
balance: state.account.balance,
};
}

The next step, which looks a bit weird, is to then use the connect() function. The function takes mapStateToProps (and optionally mapDispatchToProps) as its arguments and then returns a new function, which is then used to wrap your component.

export default connect(mapStateToProps)(BalanceDisplay);

The connect function in react-redux is a good example of a higher-order function, a concept in functional programming. A higher-order function is a function that either takes functions as arguments, returns a function, or both. In the case of connect, it returns a new function when you call it.

Now you could do this as a two-step process, but in the case of the connect() function, it gets doen in a single step…

// First call: returns a new function
const connectToStore = connect(mapStateToProps);

// Second call: returns a new component
const ConnectedBalanceDisplay = connectToStore(BalanceDisplay);

// now export the connected component
export default ConnectedBalanceDisplay

// Alternatively, all these these steps can be combined:
export default connect(mapStateToProps)(BalanceDisplay)

So even though the combined approach looks strange, it’s far more effieicent, and also means that the exported name of the component remains as BalanceDisplay, rather than having to rename it to something else such as ConnectedBalanceDisplay, as you would need to for the multi-step approach.

Whenever the Redux store’s state changes, mapStateToProps will be called, and the BalanceDisplay component will receive balance as a prop.

function BalanceDisplay({ balance }) {
return <div className="balance">{formatCurrency(balance)}</div>;
}

so now the component is fully connected and can receive the new balance each time the state changes. Here’s a full example where you could use mapStateToProps and connect instead of the more modern useSelector() and useDispatch() hooks:

// old way of connecting a component to the redux store

import { connect } from "react-redux";

function formatCurrency(value) {
return new Intl.NumberFormat("en", {
style: "currency",
currency: "USD",
}).format(value);
}

function BalanceDisplay({ balance }) {
return <div className="balance">{formatCurrency(balance)}</div>;
}

function mapStateToProps(state) {
return {
balance: state.account.balance,
};
}

export default connect(mapStateToProps)(BalanceDisplay);

Middleware and “thunks”

Middleware and thunks are used for asyncronous operations, as reducers need to remain pure. It sits between dispatching an action, and the reducer. It’s a good place for retrieving data remotely, or setting timers. Basically asynchronous information.

We use a thunk to “defer” the action whilst we wait for the data to be retrieved.

First we need to install “thunk”

npm i redux-thunk

We then import the thunk

import { thunk } from "redux-thunk";

We also need to use a special function called applyMiddleware() from redux, because thunk is technically middleware.

import { createStore, combineReducers, applyMiddleware } from "redux";

Now we have that in place, we have to tell the store that we want to use thunk, by providing it as a second argument in our createStore() function.

We do this as follows in our store:

const rootReducer = combineReducers({
account: accountReducer,
customer: customerReducer,
});

const store = createStore(
rootReducer, applyMiddleware(thunk)
);

With that in place, we now have access to thunk.

So if we switch to our slice file, we can utilize the thunk in one or more of the action creators (remember the reducer itself needs to remain pure!)

Here’s an example of where we might use thunk in an action creator…

 export function deposit(amount, currency) {
if (currency === "USD")
return {
type: "account/deposit",
payload: amount,
};

return async function (dispatch, getState) {
dispatch({ type: "account/convertingCurrency" });
// API call
const res = await fetch(
`https://api.frankfurter.app/latest?amount=${amount}&from=${currency}&to=USD`
);

const data = await res.json();
const converted = data.rates.USD;

dispatch({ type: "account/deposit", payload: converted });
};
}

So in our deposit action creator, we’re firstly checking if the selected currency in a drop-down list is USD. If it ism then we don’t need to worry about retrieving any data because xx USD is of course equal to xx USD!

However if the selected currency is something else, then we want to remotely retrieve the currency exchange rate from an external api (https://api.frankfurter.app/latest)

so what we’re actually doing here is returning a thunk! we’re returning an asynchronous function (thunk) to be dispatched.

If the action is a function (like the one returned when currency is not “USD”), redux-thunk calls this function with dispatch() and getState() as arguments. This allows the function to perform asynchronous operations and dispatch other actions based on the result.

If the action is just a regular action object (not a function), redux-thunk simply passes it on to the next middleware in line, or to the reducers if there are no more middlewares.

When any action is dispatched, it goes through the “middleware chain” before reaching the reducers. Each middleware can inspect actions, modify them, delay them, replace them, or even stop them entirely. The order in which middleware are applied is very important, as it defines the sequence of processing for each action.

So going back to the example above, you still eventually dispatch the action with a payload, only it has been “deferred” whilst we get the conversion rate via the thunk

export function deposit(amount, currency) {
if (currency === "USD")
return {
type: "account/deposit",
payload: amount,
};

// the thunk - automatically recognized as we have applied middleware
return async function (dispatch, getState) {


// first dispatch, simply to run a loading spinner while we wait
dispatch({ type: "account/convertingCurrency" });
// API call
const res = await fetch(
`https://api.frankfurter.app/latest?amount=${amount}&from=${currency}&to=USD`
);

const data = await res.json();
const converted = data.rates.USD;



// now just dispatch the action as normal with the retrieved value
dispatch({ type: "account/deposit", payload: converted });
};
}

just for completeness, here’s the reducer case for convertingCurrency, which is the first dispatch, ran while re wait for data retrival from the api

    case "account/convertingCurrency":
return {
...state,
isLoading: true,
};

So this just sets the isLoading state to true

Redux Dev Tools

To use redux dev tools, we first install it…

npm install redux-devtools-extension

// or if you have issues installing it, try this

npm install redux-devtools-extension --legacy-peer-deps

We also need to install the google chrome extension

https://chromewebstore.google.com/detail/lmhkpmbekcpmknklioeibfkpmmfibljd

The next thing we need to do is wrap our middleware function in a special function called composeWithDevTools…

import { createStore, combineReducers, applyMiddleware } from "redux";
import { thunk } from "redux-thunk";
import { composeWithDevTools } from "redux-devtools-extension";

const store = createStore(
rootReducer,
composeWithDevTools(applyMiddleware(thunk))
);

You’ll then have access to redux dev tools in your browser:

This will allow you to see the actions being dispatched, the state being updated, and also you can “jump” to different moments of when actions have been dispatched which can be useful for debugging.

Modern Redux with Redux toolkit (RTK)

Now that we’ve covered the old way of using Redux with React, we can explore the modern way. It’s worth noting that both variants can work together, and the old version isn’t going to go away.

Some of the key benefits of the modern approach with Redux Tool Kit are:

  1. We can mutate state inside reducers
  2. Action creators are automatically created for us
  3. RTK automatically sets up thunk and redux dev tools

using Redux toolkit

The first thing we need to do is install it:

npm install @reduxjs/toolkit

with Redux Toolkit we setup using the configureStore() function. This replaces the createStore() function we looked at originally, but it also replaces applyMiddleware() (needed for thunk) as well as thunk itself, and combineReducers() (previously needed when you had multiple reducers) and also composeWithDevTools().

Let’s have a look at our previous store.js file before we convert it for Redux Toolkit (RTK)

import { createStore, combineReducers, applyMiddleware } from "redux";
import { thunk } from "redux-thunk";
import { composeWithDevTools } from "redux-devtools-extension";

import accountReducer from "./features/accounts/accountSlice";
import customerReducer from "./features/customers/customerSlice";

const rootReducer = combineReducers({
account: accountReducer,
customer: customerReducer,
});

const store = createStore(
rootReducer,
composeWithDevTools(applyMiddleware(thunk))
);

export default store;

Now let’s convert this to the modern version…

import { configureStore } from "@reduxjs/toolkit";

import accountReducer from "./features/accounts/accountSlice";
import customerReducer from "./features/customers/customerSlice";

const store = configureStore({
reducer: {
account: accountReducer,
customer: customerReducer,
},
});

export default store;

So you can see the store.js file now looks a lot cleaner! We haven’t lost any of the previous functionality at all. We can still use middleware, thunk, the dev tools, and can use multiple reducers!

We specify our reducers in a simliar way, now inside configureStore()

Modern slices with Redux Toolkit

We now use a modern function called createSlice() from Redux toolkit.

Using createSlice()

Our state stays pretty much the same, so we can skip this part in our slice…

const initialState = {
balance: 0,
loan: 0,
loanPurpose: "",
isLoading: false,
};

However, our reducers now change quite dramatically…let’s quickly look at our old reducer first…

export default function accountReducer(state = initialStateAccount, action) {
switch (action.type) {
case "account/deposit":
return {
...state,
balance: state.balance + action.payload,
isLoading: false,
};
case "account/withdraw":
return {
...state,
balance: state.balance - action.payload,
};
case "account/requestLoan":
if (state.loan > 0) return state;
return {
...state,
loan: action.payload.amount,
loanPurpose: action.payload.purpose,
balance: state.balance + action.payload.amount,
};
case "account/payLoan":
return {
...state,
loan: 0,
loanPurpose: "",
balance: state.balance - state.loan,
};

case "account/convertingCurrency":
return {
...state,
isLoading: true,
};

default:
return state;
}
}

These reducers will now be inside of our createSlice() function. We start it off like this…

const accountSlice = createSlice({
name: "account",
initialState,
reducers: {

inside our createSlice function, we’re providing an object with a name, some state, and then our reducers.

In the example here, we’re giving it the name of “account” which is the feature we’re focusing on for our slice, and is the same as when we used switch/case statements in our old reducers before the forward-slash (account/)

let’s look at our reducers now. We’ll start with our first reducer action, which was “deposit”

const accountSlice = createSlice({
name: "account",
initialState,
reducers: {
deposit(state, action) {
state.balance += action.payload;
state.isLoading = false;
},

Notice how we’re now directly mutating state! We’re setting state.balance to be action.payload, and we’re setting state.isLoading to be false.

Let’s compare once more to classic redux for the same thing…

export default function accountReducer(state = initialStateAccount, action) {
switch (action.type) {
case "account/deposit":
return {
...state,
balance: state.balance + action.payload,
isLoading: false,
};

So in classic redux, we first take the entire state object and spread it, and then we provide new values for balance and isLoading.

Implicit Action Creators

In classic Redux, we explicitly define our action creators. For example:

function deposit(amount) {
return {type: "account/deposit", payload: amount}
};

However in Redux toolkit, they are implicitly created when we use createSlice. This basically means that you can’t see the code to read them, but they do exist!

let’s bring up our first few lines of code for createSlice once more…

const accountSlice = createSlice({
name: "account",
initialState,
reducers: {
deposit(state, action) {
state.balance += action.payload;
state.isLoading = false;
},

Implicit Creation: When you define a slice using createSlice, Redux Toolkit implicitly creates action creators for each reducer function you define in the reducers object of the slice.

So in the example above, redux toolkit will have created an action creator called “deposit”, and the action type would automatically be called account/deposit because that’s the name we’ve given for our slice, and this get’s combined with our action creator name:

function deposit(amount) {
return {type: "account/deposit", payload: amount}
};

So even though you can’t see it, there is now an action creator, ready to be exported, called “deposit”!

Downside of automatically-generated action creators

One downside of automatically-generated action creators, is that by default, they are only designed to accept a single value for the payload. This is fine if we only need to send one value, but what about in this scenario we had with an action creator in classic redux?

function requestLoan(loanAmount, loanPurpose) {
return {
type: "account/requestLoan",
payload:{amount: loanAmount, purpose: loanPurpose}
}
};

this was designed to accept two values — a loanAmount AND a loanPurpose. With createSlice() we can’t accommodate this automatically, so we need to use a special method called prepare() to first prepare the data.

    requestLoan: {
prepare(amount, purpose) {
return {
payload: {
amount,
purpose,
},
};
},
reducer(state, action) {
if (state.loan > 0) return;
state.loan = action.payload.amount;
state.loanPurpose = action.payload.purpose;
state.balance += action.payload.amount;
},
},

so here you can see that our action creator will be called requestLoan (as it would’ve been in classic redux) only now, we’re utilizing the prepare() method to state that we want to pass in two parameters, which returns a new payload object

      prepare(amount, purpose) {
return {
payload: {
amount,
purpose,
},
};
},

Once the payload data is prepared, we can then pass both the amount AND purpose into the reducer as normal.

      reducer(state, action) {
if (state.loan > 0) return;
state.loan = action.payload.amount;
state.loanPurpose = action.payload.purpose;
state.balance += action.payload.amount;
},

Another good use case for prepare is setting timers, or ading dates. For example:

const customerSlice = createSlice({
name: "customer",
initialState,
reducers: {
createCustomer: {
prepare(fullName, nationalId) {
return {
payload: {
fullName,
nationalId,
createdAt: new Date().toISOString(),
},
};
},
reducer(state, action) {
state.fullName = action.payload.fullName;
state.nationalId = action.payload.nationalId;
state.createdAt = action.payload.createdAt;
},
},

So here we’re setting a createdAt time and date inb our prepare function, which then gets passed to the reducer.

Dealing with “thunk” for asynchronous code

In the example for classic redux, we used the following code inside our action creator to obtain currency conversion:

export function deposit(amount, currency) {
if (currency === "USD")
return {
type: "account/deposit",
payload: amount,
};

return async function (dispatch, getState) {
dispatch({ type: "account/convertingCurrency" });
// API call
const res = await fetch(
`https://api.frankfurter.app/latest?amount=${amount}&from=${currency}&to=USD`
);

const data = await res.json();
const converted = data.rates.USD;

dispatch({ type: "account/deposit", payload: converted });
};
}

so here we return a function (which is picked up by middleware as a thunk) which eventually returns a value that can be dispatched.

Now it’s still perfectly valid to take this approach, however the more modern approach would be to use the createAsyncThunk() utility.

We first import it:

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

Next we define our thunk:

export const deposit = createAsyncThunk(
"account/deposit",
async ({ amount, currency }) => {
if (currency === "USD") {
return amount; // Directly return the amount for USD
}
const response = await fetch(
`https://api.frankfurter.app/latest?amount=${amount}&from=${currency}&to=USD`
);
const data = await response.json();
return data.rates.USD; // This will be the fulfilled action payload
}
);

Now inside our createSlice, we use something called extraReducers() to deal with our thunk.

The extraReducers option within createSlice from Redux Toolkit is designed to handle actions that are NOT directly related to the slice itself, including those generated by createAsyncThunk or any other actions that you might want to respond to within the slice’s reducers. This feature allows a slice to react to actions defined outside of its own reducers definition, providing a clean and organized way to manage the state in response to actions from other slices or asynchronous actions.

Crucially, extraReducers provides a way to listen for actions without automatically generating action creators as the reducers field does.

Builder calback function

The builder callback is a function that receives an object with methods to define how the slice’s state should respond to actions.

addCase(actionCreator, reducer): Allows you to respond to a specific action.

This is how we’d use it:

  extraReducers: (builder) => {
builder.addCase(deposit.fulfilled, (state, action) => {
state.balance += action.payload;
state.isLoading = false;
});

So what we’re saying here is that if the request for the currency conversion gets fulfilled, then run the reducer whereby the state.balance is updated, and also state.isLoading.

You can add more cases, for pending and rejected if you want to. For example:

  extraReducers: (builder) => {
builder
.addCase(deposit.pending, (state) => {
state.isLoading = true;
})
.addCase(deposit.fulfilled, (state, action) => {
state.balance += action.payload;
state.isLoading = false;
})
.addCase(deposit.rejected, (state, action) => {
state.isLoading = false;
});
},

So you can see that addCase is used to handle each of the lifecycle actions (pending, fulfilled, rejected) associated with the fetchUserById async thunk. This approach cleanly separates the logic for updating the state based on different outcomes of the thunk action.

Here’s what the entire re-factored slice file might look like:

import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";

const initialState = {
balance: 0,
loan: 0,
loanPurpose: "",
isLoading: false,
};

// Define the async thunk
export const deposit = createAsyncThunk(
"account/deposit",
async ({ amount, currency }) => {
if (currency === "USD") {
return amount; // Directly return the amount for USD
}
const response = await fetch(
`https://api.frankfurter.app/latest?amount=${amount}&from=${currency}&to=USD`
);
const data = await response.json();
return data.rates.USD; // This will be the fulfilled action payload
}
);

const accountSlice = createSlice({
name: "account",
initialState,
reducers: {
// deposit(state, action) {
// state.balance += action.payload;
// state.isLoading = false;
// },
withdraw(state, action) {
state.balance -= action.payload;
},
requestLoan: {
prepare(amount, purpose) {
return {
payload: {
amount,
purpose,
},
};
},
reducer(state, action) {
if (state.loan > 0) return;
state.loan = action.payload.amount;
state.loanPurpose = action.payload.purpose;
state.balance += action.payload.amount;
},
},
payLoan(state) {
state.balance -= state.loan;
state.loan = 0;
state.loanPurpose = "";
},
convertingCurrency(state) {
state.isLoading = true;
},
},
extraReducers: (builder) => {
builder
.addCase(deposit.pending, (state) => {
state.isLoading = true;
})
.addCase(deposit.fulfilled, (state, action) => {
state.balance += action.payload;
state.isLoading = false;
});
},
});

export const { withdraw, requestLoan, payLoan } = accountSlice.actions;

Note that the dispatch itself needs to be formatted in a certain way when dealing with thunks.

When you dispatch a thunk created with createAsyncThunk, you need to ensure that the payload you’re passing matches the structure expected by the thunk. For a thunk expecting an object with amount and currency properties, you should dispatch it like this:

dispatch(deposit({ amount: depositAmount, currency: currency }));

If you dispatch the thunk without providing an object with the expected properties, then amount and currency will be undefined because the thunk is expecting a single argument: an object.

So ensure it’s passed in like this!

async ({ amount, currency }) => {

Re-using a reducer with caseReducers

There may be occasions in your reducer where you just want to re-use another reducer as part of a new reducer. Rather than copy/pasting the code, you can just use caseReducers to reference the reducer you want to use.

For example, here’s a reducer for deleting an item…

const cartSlice = createSlice({
name: "cart",
initialState,
reducers: {

addItem: (state, action) => {
state.cart.push(action.payload);
},

// deleting an item
deleteItem: (state, action) => {
state.cart = state.cart.filter((item) => item.pizzaId !== action.payload);
},

We might want to create another reducer that also eventually deletes an item, so we can reference this deleteItem reducer to user it in our if statement…

    decreaseItemQuantity(state, action) {
const item = state.cart.find((item) => item.pizzaId === action.payload);
item.quantity--;
item.totalPrice = item.quantity * item.unitPrice;

// re-use the deleteItem reducer with caseReducers!
if (item.quantity === 0) cartSlice.caseReducers.deleteItem(state, action);
},

So if we click on a button to decrease a quanity, it wil also delete the item completely if the quantity reaches 0.

--

--

Jeff P

I tend to write about anything I find interesting. There’s not much more to it than that really :-)