A Practical Introduction to Using Redux with React

2 Feb, 2020
  • Share
Post image

Introduction

In this tutorial I'd like to explain briefly what is Redux and how to set it up in a React project.

This tutorial would be useful for you if you've learned React already and would like to learn how Redux can help managing the app's state globally.

Many of us have heard that the Redux code we have to write using original Redux API is a little verbose (e.g., initializing store, creating actions and reducers, etc). Therefore, the team behind Redux created a package called Redux Toolkit which makes developing Redux apps easier and more fun. Moreover, using Redux Toolkit for writing Redux logic is official recommended approach.

In this tutorial we'll start with the basics, then we'll build a simple app using just Redux (without the toolkit). Finally, we'll add Redux Toolkit to improve our Redux code.

So, let's get started.

What is Redux?

Redux is a state management library. Commonly, it is used together with React, but it can be used with other view libraries too. Redux helps us to keep the state of the whole app in a single place.

What is the State?

I would describe "state" as the data that is used to render the app at any given time. We keep this data in a JavaScript object. For example, in a simple app which renders a list of muffins, the state could look like this:

let state = {
  muffins: [
    { name: 'Chocolate chip muffin' },
    { name: 'Blueberry muffin' },
  ],
};

How to Modify the State?

To modify the state from within a component we dispatch an action:

// SomeComponent.js
dispatch({
  type: 'muffins/add',
  payload: {
    muffin: { name: 'Banana muffin' },
  },
});

Dispatching actions is the only way to change the state.

An action is represented by an object with the type property. The type property is the action's name. You can add any other property to this object (this is how you pass the data to reducer).

There are no formal rules as to how you should name your actions. Give your actions descriptive and meaningful names. Don't use ambiguous names, like receive_data or set_value.

It is a common practice to share actions through the action creator functions. Such functions create and return the action objects. We store action creators outside of the component files (e.g., src/redux/actions.js). This makes it easy to see what actions are available in the app and to maintain and reuse them.

// actions.js
export function addMuffin(muffin) {
  return {
    type: 'muffins/add',
    payload: { muffin },
  };
}

// SomeComponent.js
dispatch(addMuffin({ name: 'Banana muffin' }));

Once an action is dispatched, Redux calls the reducer with the previous state and the dispatched action object as the arguments. Reducer is a function which decides how to change the state according to a given action. We create this function and register it with Redux.

This is how a basic reducer looks like:

let initialState = {
  muffins: [
    { id: 1, name: 'Chocolate chip muffin' },
    { id: 2, name: 'Blueberry muffin' },
  ],
};

function reducer(state = initialState, action) {
  switch (action.type) {
    case 'muffins/add':
      let { muffin } = action.payload;
      return {
        ...state,
        muffins: [...state.muffins, muffin],
      };
    default:
      return state;
  }
}

When this reducer identifies the muffins/add action it adds the given muffin to the list.

IMPORTANT. The reducer copies the previous state object instead of mutating it. The rule is that the state must be immutable (read-only). The reducer should copy any object that it would like to change before changing it. This includes the root object and any nested objects.

We need to copy the state for Redux to be able to check (using shallow checking) if the state returned by the reducer is different from the previous state. Check this for more details about shallow checking: How do shallow and deep equality checking differ?. It is important to follow this rule for Redux to respond to our state changes correctly. Also, when using redux with react-redux this helps react-redux to decide which components should be re-rendered when the state changes.

The other important rule is that the reducer function should be pure. Given the same input it should always produce the same output without causing any side effects. A side affect is something that reads or changes the environment around the function. Examples of side effects are reading or writing a global variable, running a network request, etc. This rule helps us reproduce the look and behavior of the app given a particular state object.

Also, both of these rules make sure that Redux's time travel feature works properly with our app. Time travel allows us to easily undo actions and then apply them again. This helps a lot with debugging using Redux DevTools.

To summarize:

  • Our app has a single state.
  • To change this state we dispatch actions.
  • The reducer function handles the dispatched actions and changes the state accordingly.
  • Redux and react-redux check the state returned by the reducer for changes using shallow checking.

Unidirectional Data Flow

So, we've learned the following about Redux: we dispatch an action from the view layer (e.g., a React component), reducer gets this action and changes the state accordingly, the store notifies the view layer about the state change and the view layer renders the app according to the latest state. And the cycle repeats when we need to change the state again.

So, the data in a Redux app flows in a single way circular pattern. It is also called a unidirectional data flow. This is how we could represent it using a diagram:

redux-data-flow

This pattern makes it easier to understand how a Redux app works.

Setting Up Redux in a React App

In this post we will be building a simple app which lists a number of muffins.

I've initialized a basic React app using create-react-app:

npx create-react-app my-react-redux --use-npm

I removed extra code and rendered a hard-coded list of muffins. This is what I've got: View on GitHub

Let's go ahead and store the muffins in the state.

First, let's install "redux" and "react-redux" packages:

npm i -S redux react-redux

Remember, Redux can be used with other view libraries. So we need the "react-redux" package to connect React components with Redux store.

Next, we should prepare the Redux store. The store is an object which keeps the app's state and provides the API for working with it. It allows us to:

  • read the state
  • dispatch actions to change the state
  • and subscribe/unsubscribe to/from the state changes

IMPORTANT. Your app should have a single store.

Let's go ahead and set up the store for our example app.

Let's keep the Redux functionality in the folder called "redux":

mkdir src/redux

Let's write the store initialization code in the file src/redux/store.js:

Pure Redux

// File: src/redux/store.js
import { createStore } from 'redux';

const initialState = {
  muffins: [
    { id: 1, name: 'Chocolate chip muffin' },
    { id: 2, name: 'Blueberry muffin' },
  ],
};

const reducer = (state = initialState, action) => {
  switch (action.type) {
    default:
      return state;
  }
};

const store = createStore({ reducer });

export default store;

We use the createStore function from Redux to create the store. When the store initializes, it obtains the initial state by calling our reducer function with undefined for the state and a dummy action (e.g., reducer(undefined, { type: 'DUMMY' })).

Now we should provide the store to the React components. For this, we open the src/index.js and wrap the <App /> component into the <Provider /> component from the "react-redux" package:

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import './index.css';
import App from './components/App';
import store from './redux/store';

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);

The <Provider /> component provides the store to the child component tree using React context. Now we can use the React hooks or the connect function from the "react-redux" package to get the state and dispatch actions from any component in the tree.

View the Code on GitHub

Using React Hooks to Read the State

Instead of hard-coding the muffin list in the "Muffins.js", let's use the useSelector hook from "react-redux" to select the muffins array from the state.

// file: src/redux/selectors.js
export const selectMuffinsArray = (state) => state.muffins;
// file: src/components/Muffins/Muffins.js
import React from 'react';
import { useSelector } from 'react-redux';
import { selectMuffinsArray } from '../../redux/selectors';

const Muffins = () => {
  const muffins = useSelector(selectMuffinsArray);

  return (
    <ul>
      {muffins.map((muffin) => {
        return <li key={muffin.id}>{muffin.name}</li>;
      })}
    </ul>
  );
};

export default Muffins;

The useSelector hook expects a selector function as the first argument. We create selector functions to provide a reusable API for selecting different parts of the state.

We use the state in many components. If we select things from the state directly (e.g. let muffins = state.muffins) and at some point we change the structure of the state (e.g., state.muffins becomes state.muffins.items) we'd have to edit each component where we access the state properties directly. Using selector functions we can change the way we select the state in a single place (in our example, it is the "selectors.js" file).

View the Code on GitHub

Using React Hooks to Dispatch Actions

Let's add a "Like" button to each muffin in the list.

First, let's add the "likes" property to the state (number of likes).

// file: src/redux/store.js
const initialState = {
  muffins: [
    { id: 1, name: 'Chocolate chip muffin', likes: 11 },
    { id: 2, name: 'Blueberry muffin', likes: 10 },
  ],
};

Next, let's render the number of likes and the "Like" button.

// file: src/components/Muffins/Muffins.js
<li key={muffin.id}>
  {muffin.name} <button>Like</button> <i>{muffin.likes}</i>
</li>

Now, let's get the dispatch function in the component using the useDispatch hook from "react-redux".

// file: src/components/Muffins/Muffins.js
import { useSelector, useDispatch } from 'react-redux';
// ...
const dispatch = useDispatch();

Let's define an action for the "Like" button.

// File: src/redux/actions.js
export const likeMuffin = (muffinId) => ({
  type: 'muffins/like',
  payload: { id: muffinId },
});

Next, let's create the "click" event handler for the "Like" button:

// file: src/components/Muffins/Muffins.js
import { likeMuffin } from '../../redux/actions';

// ...

{
  muffins.map((muffin) => {
    const handleLike = () => {
      dispatch(likeMuffin(muffin.id));
    };
    return (
      <li key={muffin.id}>
        {muffin.name}{' '}
        <button onClick={handleLike}>Like</button>{' '}
        <i>{muffin.likes}</i>
      </li>
    );
  });
}

If we click this button, nothing happens, because we didn't create a reducer for the action that is dispatched (muffins/like).

So, let's go ahead and reduce this action.

// file: src/redux/store.js
const reducer = (state = initialState, action) => {
  switch (action.type) {
    case 'muffins/like':
      const { id } = action.payload;
      return {
        ...state,
        muffins: state.muffins.map((muffin) => {
          if (muffin.id === id) {
            return { ...muffin, likes: muffin.likes + 1 };
          }
          return muffin;
        }),
      };
    default:
      return state;
  }
};

It's important NOT to mutate the state. So, I copy the state object, copy the muffins array (the map method returns a new array). Finally, I copy only the muffin which is being changed. I don't touch the other muffins to signify that they do not change.

Now, if we click the "Like" button, the muffins/like action is dispatched and the reducer changes the state accordingly. The number of likes of the chosen muffin increments.

View the Code on GitHub

Using "json-server" for the Local Fake API

"json-server" is a fake REST API server which is really easy to set up. We can use it to mock API endpoints while working on a front end app. I'd like to use this server for the examples in this post. So let me show you how to install and run it.

To install:

npm i -D json-server

To tell the server what data it should serve we create a JSON file. Let's call it db.json.

{
  "muffins": [
    {
      "id": 1,
      "name": "Chocolate chip muffin",
      "likes": 11
    },
    { "id": 2, "name": "Blueberry muffin", "likes": 10 }
  ]
}

Now let's open package.json and add the script which will start this server:

"scripts": {
  "json-server": "json-server --watch db.json --port 3001"
}

To run it:

npm run json-server

The server should start on http://localhost:3001.

To stop it, focus on the terminal window where you started it and press CTRL + C.

We can use the following routes ("json-server" generates them by looking at db.json)

GET /muffins
POST /muffins
PUT /muffins/{id}
DELETE /muffins/{id}

View the code on GitHub

Async Actions

Please check the section Using "json-server" for the Local Fake API.

Usually, we run network requests to get and edit the data. Let's see how to do it the Redux way.

By default, Redux allows us to dispatch an action only in the form of an object with the type property.

However, Redux allows us to alter the way it dispatches actions using a middleware function. One such function is called "redux-thunk".

Let's install and register this middleware function with Redux.

npm i -S redux-thunk
// file: src/redux/store.js
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
// ...
const store = createStore(reducer, applyMiddleware(thunk));

applyMiddleware is a utility function which takes a list of middleware functions and groups them in a single middleware function which we pass to createStore as the second argument.

Also, let's empty the muffins array in the initial state, because we are going to load muffins from the fake API.

// file: src/redux/store.js
const initialState = {
  muffins: [],
};

"redux-thunk" allows us to dispatch not only objects, but also functions:

dispatch((dispatch, getState) => {
  let state = getState();
  // do something async and
  dispatch(/* some action */);
});

The thunk function gets the original dispatch function as the first argument and the getState function as the second argument.

So, what we can do with a thunk function is, for example, to fetch the data from the network and when the data is ready we can dispatch an action object with this data, so reducer can add this data to the state.

Let's create the actions.js file and add the async action creator function for loading muffins.

// file: src/redux/actions.js
export const loadMuffins = () => async (dispatch) => {
  dispatch({
    type: 'muffins/load_request',
  });

  try {
    const response = await fetch(
      'http://localhost:3001/muffins'
    );
    const data = await response.json();

    dispatch({
      type: 'muffins/load_success',
      payload: {
        muffins: data,
      },
    });
  } catch (e) {
    dispatch({
      type: 'muffins/load_failure',
      error: 'Failed to load muffins.',
    });
  }
};

A thunk function can be either sync or async. We can dispatch multiple actions in this function. In our example we dispatch the muffins/load_request action to signify that the request starts. We can use this action to show a spinner somewhere in the app. Then, when the request succeeds we dispatch the muffins/load_success action with the fetched data. Finally, if the request fails, we dispatch the muffins/load_failure action to show the error message to the user.

Now, let's create the reducers for these actions.

// file: src/redux/store.js
const reducer = (state = initialState, action) => {
  switch (action.type) {
    // ...
    case 'muffins/load_request':
      return { ...state, muffinsLoading: true };

    case 'muffins/load_success':
      const { muffins } = action.payload;
      return { ...state, muffinsLoading: false, muffins };

    case 'muffins/load_failure':
      const { error } = action;
      return { ...state, muffinsLoading: false, error };
    // ...
  }
};

Let's dispatch the loadMuffins action in the Muffins component, when it mounts.

// file: src/components/Muffins/Muffins.js
import React, { useEffect } from 'react';
import { loadMuffins } from '../../redux/actions';

// ...

const dispatch = useDispatch();

useEffect(() => {
  dispatch(loadMuffins());
}, []);

We are loading muffins in the effect hook, because dispatching an action is a side effect.

Finally, let's handle the loading and error states.

Create the following selector functions:

// file: src/redux/selectors.js
export const selectMuffinsLoading = (state) =>
  state.muffinsLoading;
export const selectMuffinsLoadError = (state) =>
  state.error;

And render the loading and error messages:

// file: src/components/Muffins/Muffins.js
const muffinsLoading = useSelector(selectMuffinsLoading);
const loadError = useSelector(selectMuffinsLoadError);

// ...

return muffinsLoading ? (
  <p>Loading...</p>
) : loadError ? (
  <p>{loadError}</p>
) : muffins.length ? (
  <ul>
    {muffins.map((muffin) => {
      // ...
    })}
  </ul>
) : (
  <p>Oh no! Muffins have finished!</p>
);

Now, let's check if we did everything correctly.

We should run the local "json-server" and the app.

In one terminal window:

npm run json-server

And in the other:

npm start

In the browser you should see the list of muffins which is, now, fetched from the fake API server.

View the code on GitHub

Multiple Reducers

Usually, in a large app, state won't be that simple. It will look like a huge tree of data.

The reducer function will become bloated.

So, it's a good idea to split the reducer into multiple smaller reducers where each reducer handles only a part of the state.

For example, in order to handle the state from the picture above, it would be a good idea to create 3 reducers:

const muffinsReducer = (
  state = initialMuffinsState,
  action
) => {
  // ...
};
const notificationsReducer = (
  state = initialNotificationsState,
  action
) => {
  // ...
};
const cartReducer = (state = initialCartState, action) => {
  // ...
};

and combine them using the utility function called combineReducers:

const rootReducer = combineReducers({
  muffins: muffinsReducer,
  notifications: notificationsReducer,
  cart: cartReducer,
});

const store = createStore(rootReducer);

combineReducers creates a root reducer function which calls each sub reducer when the action is dispatched and combines the parts of the state they return into a single state object:

{
  muffins: ...,
  notifications: ...,
  cart: ...
}

Combining reducers makes it easy to modularize the reducer logic.

Feature Folders and Ducks

The Redux documentation recommends structuring Redux functionality as feature folders or ducks.

Feature Folders

Instead of grouping all actions and reducers by the type of code (for example, all the app's actions in actions.js and all reducers in reducers.js), we could group them by feature.

Let's say there are two features: "users" and "notifications". We could keep their actions and reducers in separate folders. For example:

redux/
  users/
    actions.js
    reducers.js
  notifications/
    actions.js
    reducers.js
  store.js

Ducks

The "ducks" pattern says that we should keep all Redux logic (actions, reducers, selectors) for a specific feature in its own file. For example:

redux/
  users.js
  notifications.js
  store.js

Using the "Ducks" Pattern in Our Example App

In the app we've got different Redux functionality around muffins. We can group this functionality into a duck. In other words, let's just move everything related to mufffins into a JavaScript file and call it src/redux/muffins.js.

Let's move the actions, selectors and the reducer to this file:

export const likeMuffin = (muffinId) => ({
  type: 'muffins/like',
  payload: { id: muffinId },
});

export const loadMuffins = () => async (dispatch) => {
  dispatch({
    type: 'muffins/load_request',
  });

  try {
    const response = await fetch(
      'http://localhost:3001/muffins'
    );
    const data = await response.json();

    dispatch({
      type: 'muffins/load_success',
      payload: {
        muffins: data,
      },
    });
  } catch (e) {
    dispatch({
      type: 'muffins/load_failure',
      error: 'Failed to load muffins.',
    });
  }
};

export const selectMuffinsArray = (state) => state.muffins;
export const selectMuffinsLoading = (state) =>
  state.muffinsLoading;
export const selectMuffinsLoadError = (state) =>
  state.error;

const initialState = {
  muffins: [],
};

const reducer = (state = initialState, action) => {
  switch (action.type) {
    case 'muffins/like':
      const { id } = action.payload;
      return {
        ...state,
        muffins: state.muffins.map((muffin) => {
          if (muffin.id === id) {
            return { ...muffin, likes: muffin.likes + 1 };
          }
          return muffin;
        }),
      };

    case 'muffins/load_request':
      return { ...state, muffinsLoading: true };

    case 'muffins/load_success':
      const { muffins } = action.payload;
      return { ...state, muffinsLoading: false, muffins };

    case 'muffins/load_failure':
      const { error } = action;
      return { ...state, muffinsLoading: false, error };

    default:
      return state;
  }
};

export default reducer;

Now, in the src/redux/store.js, let's create the root reducer using the combineReducers function:

// File: src/redux/store.js
import {
  createStore,
  applyMiddleware,
  combineReducers,
} from 'redux';
import thunk from 'redux-thunk';
import muffinsReducer from './muffins';

const rootReducer = combineReducers({
  muffins: muffinsReducer,
});

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

export default store;

Now, the app's state looks like this:

{
  muffins: {
    muffins: [],
    muffinsLoading: boolean,
    error: string
  }
}

Since the structure of the state has changed, to make the app work, we should update the parts of the code where we read the state. Luckily, we use selector functions to select parts of the state object instead of working with the state object directly. So, we only have to update the selector functions:

// File: src/redux/muffins.js
export const selectMuffinsState = (rootState) =>
  rootState.muffins;

export const selectMuffinsArray = (rootState) =>
  selectMuffinsState(rootState).muffins;

export const selectMuffinsLoading = (rootState) =>
  selectMuffinsState(rootState).muffinsLoading;

export const selectMuffinsLoadError = (rootState) =>
  selectMuffinsState(rootState).error;

Finally, let's update the import statements:

// File: src/components/Muffins/Muffins.js
import {
  selectMuffinsArray,
  selectMuffinsLoading,
  selectMuffinsLoadError,
} from '../../redux/muffins';
import {
  likeMuffin,
  loadMuffins,
} from '../../redux/muffins';

That's it! We used the "ducks" pattern to move the Redux functionality around managing the muffins state into a single file.

View the code on GitHub

The Redux team recommends to use the Redux Toolkit for writing Redux logic. This toolkit contains a set of utilities that make it easier to write Redux apps. Pure Redux is a little verbose, so this toolkit wraps the complex code you had to write using pure Redux in utilities that help you writing less code. Also, it includes additional libraries that are commonly used with Redux.

Let's improve our Redux code using Redux Toolkit.

The toolkit is distributed as a separate package. Let's install it:

npm i -S @reduxjs/toolkit

Then, let's open src/redux/store.js and update it to initialize the store using Redux Toolkit.

// src/redux/store.js
import { configureStore } from '@reduxjs/toolkit';
import muffinsReducer from './muffins';

const store = configureStore({
  reducer: {
    muffins: muffinsReducer,
  },
});

export default store;

We replaced createStore, applyMiddleware, combineReducers, and redux-thunk with a single function, configureStore. This function wraps the Redux createStore, adds default configuration and provides additional functionality for configuring the store.

configureStore applies the thunk middleware by default, so we don't have to set it up manually and there is no need to install the redux-thunk package as well. Also, this function combines reducers for us, so we no longer need Redux combineReducers. We add the reducers for handling different parts of the state to the reducer object.

To learn more about configureStore please visit it's documentation.

Redux Toolkit includes wonderful functionality for helping us with creating reducers. There is a function called createReducer(initialState, caseReducers). The first argument is the initial state and the second argument is an object that maps action types to reducer functions that handle that actions.

Let's go ahead and use createReducer to create our reducer. In src/redux/muffins.js please replace the old reducer code with the new:

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

// ...

const reducer = createReducer(initialState, {
  'muffins/like': (state, action) => {
    const { id } = action.payload;

    return {
      ...state,
      muffins: state.muffins.map((muffin) => {
        if (muffin.id === id) {
          return { ...muffin, likes: muffin.likes + 1 };
        }
        return muffin;
      }),
    };
  },

  'muffins/load_request': (state) => {
    return { ...state, muffinsLoading: true };
  },

  'muffins/load_success': (state, action) => {
    const { muffins } = action.payload;
    return { ...state, muffinsLoading: false, muffins };
  },

  'muffins/load_failure': (state, action) => {
    const { error } = action;
    return { ...state, muffinsLoading: false, error };
  },
});

This looks better already, it's more declarative and each action is handled by its own reducer function compared to the switch statement where the scope is shared between case's.

We shouldn't stop here, we can improve this reducer even further with the help of createReducer.

Earlier in this post I've told that when changing the state a reducer function should not mutate the previous state. That's why in our reducer we always return a new state object and copy the parts of the state we're changing, creating new references for Redux to be able to quickly compare previous state with the new state to find out if the state changed.

In the createReducer function we no longer need to copy the state object, we can mutate it directly. This function applies Immer to turn our mutation to an immutable update. Let's turn our difficult to read immutable state update code to the mutable easy to read version which will be handled by Immer behind the scenes to make it immutable:

const reducer = createReducer(initialState, {
  'muffins/like': (state, action) => {
    const muffinToLike = state.muffins.find(
      (muffin) => muffin.id === action.payload.id
    );
    muffinToLike.likes += 1;
  },

  'muffins/load_request': (state) => {
    state.muffinsLoading = true;
  },

  'muffins/load_success': (state, action) => {
    state.muffinsLoading = false;
    state.muffins = action.payload.muffins;
  },

  'muffins/load_failure': (state, action) => {
    state.muffinsLoading = false;
    state.error = action.error;
  },
});

This code is much more readable, isn't it? However, there are a few gotchas. It's important that when modifying the state in a reducer you either mutate the state argument or return a new state. You can't do both. Also, please read about pitfalls of using Immer in its documentation.

IMPORTANT. You can mutate the state only inside the createReducer and createSlice functions. I'll talk about createSlice later.

Please have a look at the createReducer docs (https://redux-toolkit.js.org/api/createReducer) to learn more about it.

Now let's check what can we do with our actions. Redux Toolkit provides a helper function for generating action creators called createAction.

Let's generate our likeMuffin action using createAction:

// src/redux/muffins.js
import {
  createReducer,
  createAction,
} from '@reduxjs/toolkit';

// export const likeMuffin = (muffinId) => ({
//   type: 'muffins/like',
//   payload: { id: muffinId },
// });
export const likeMuffin = createAction(
  'muffins/like',
  (muffinId) => {
    return { payload: { id: muffinId } };
  }
);

createAction takes two arguments. The first one is the action type and it's required. The second argument is a so-called prepare function which you can use to accept arguments from the resulting action creator and attach these arguments as additional data to the action object. The prepare function is optional.

The actions created by createAction have their toString methods overridden such that they return the action type. So, if we place our new likeMuffin action creator where JS expects a string, likeMuffin will be turned into the "muffins/like" string through the likeMuffin.toString() method. This means that we can use our new action creator as action type key in our reducer:

// src/redux/muffins.js
const reducer = createReducer(initialState, {
  // 'muffins/like': (state, action) => {
  [likeMuffin]: (state, action) => {
    // ...
  },
  // ...
});

The other action we have - loadMuffins - is a thunk action. For generating thunk action creators Redux Toolkit provides a helper function called createAsyncThunk. Let's use this function to redo our loadMuffins thunk action:

// src/redux/muffins.js
export const loadMuffins = createAsyncThunk(
  'muffins/load',
  async () => {
    const response = await fetch(
      'http://localhost:3001/muffins'
    );
    const muffins = await response.json();
    return { muffins };
  }
);

createAsyncThunk takes the action type as the first argument and the callback function as the second argument. The callback function should return a promise. Whatever the promise resolves with will be added to the action object's payload property.

createAsyncThunk returns a thunk action creator. When we dispatch this action creator, based on the promise we return from the callback, it dispatches the following lifecycle actions: pending (muffins/load/pending), fulfilled (muffins/load/fulfilled) and rejected (muffins/load/rejected). The types of these lifecycle actions are available as the properties of the action creator (e.g., loadMuffins.pending).

So, let's use these types in our reducer instead of our own muffins/load_request, muffins/load_success, muffins/load_failure:

// src/redux/muffins.js
const reducer = createReducer(initialState, {
  // ...
  [loadMuffins.pending]: (state) => {
    state.muffinsLoading = true;
  },

  [loadMuffins.fulfilled]: (state, action) => {
    state.muffinsLoading = false;
    state.muffins = action.payload.muffins;
  },

  [loadMuffins.rejected]: (state) => {
    state.muffinsLoading = false;
    state.error = 'Failed to load muffins.';
  },
});

Finally, we can group the Redux functionality related to a single feature (like muffins) into a so-called "slice" (or "duck"). To accomplish this we'll use the createSlice function. Let's open src/redux/muffins.js and reorganize our Redux logic using createSlice:

// src/redux/muffins.js
import {
  createAsyncThunk,
  createSlice,
} from '@reduxjs/toolkit';

// ...

// Selectors...

// ...

const muffinsSlice = createSlice({
  name: 'muffins',
  initialState,
  reducers: {
    likeMuffin: {
      reducer: (state, action) => {
        const muffinToLike = state.muffins.find(
          (muffin) => muffin.id === action.payload.id
        );
        muffinToLike.likes += 1;
      },
      prepare: (muffinId) => {
        return { payload: { id: muffinId } };
      },
    },
  },
  extraReducers: {
    [loadMuffins.pending]: (state) => {
      state.muffinsLoading = true;
    },

    [loadMuffins.fulfilled]: (state, action) => {
      state.muffinsLoading = false;
      state.muffins = action.payload.muffins;
    },

    [loadMuffins.rejected]: (state) => {
      state.muffinsLoading = false;
      state.error = 'Failed to load muffins.';
    },
  },
});

export const { likeMuffin } = muffinsSlice.actions;

export default muffinsSlice.reducer;

This change looks a little confusing at first. So, let's discuss it part by part.

First, we no longer need createReducer and createAction, because createSlice creates the reducer function and basic (non thunk) actions for us.

createSlice expects the name of the slice, we can name it after the feature we create the slice for, like muffins. The name is used as a prefix for action types that are created by createSlice from the reducers option.

Then, we provide the initialState of the slice.

Next, createSlice gives two options for creating reducers: reducers and extraReducers.

We use reducers to create both, actions and corresponding reducers. The reducers option is an object which maps an action type to a corresponding reducer function. createSlice takes this map and generates actions and reducers from it. If an action doesn't need to keep any data apart from the action type we can create an action and reducer like this:

createSlice({
  name: 'someSliceName',
  reducers: {
    helloWorld: (state) => {
      state.message = 'Hello World';
    },
  },
});

This creates the action creator function called helloWorld which returns the following action object: { type: 'someSliceName/helloWorld' }. If we need to add additional data to the action object, like some payload, we can add the prepare function:

createSlice({
  name: 'someSliceName',
  reducers: {
    helloWorld: {
      reducer: (state, action) => {
        state.message = `Hello, ${action.payload.name}`;
      },
      prepare: (name) => {
        return { payload: { name } };
      },
    },
  },
});

This example creates the action creator helloWorld(name) which takes the name argument and returns the following action object: { type: 'someSliceName/helloWorld', payload: { name } }.

We can use extraReducers to create reducers for existing actions and thunk actions. Basically, you can import an action from another slice and handle it here. In our example we use extraReducers to handle the lifecycle actions of the loadMuffins thunk action.

The difference between reducers and extraReducers is that createSlice does not auto-generate action creators for reducers in extraReducers.

Both, reducers and extraReducers allow us to mutate the state argument, because both of them will be passed to createReducer which uses Immer to turn our state mutation to an immutable update.

createSlice returns an object with the following structure:

{
  name: name of the slice
  reducer: reducer function that combines reducers from `reducers` and `extraReducers` options
  actions: action creators extracted from the `reducers` option
  caseReducers: reducer functions from the `reducers` option
}

In our example we extract the action creators from the muffinsSlice.actions and export them separately to make it easier to import and use them in other components. And we export the reducer function by default.

So, with the help of Redux Toolkit our code became shorter and more declarative which makes it easier to read and understand it.

So, you've completed the Redux + React intro tutorial. I tried to make it as short and as simple as I could. I recommend you to check the Redux Essentials Series in the Redux docs and the Redux Toolkit website. Both of them cover a lot of details, best practices and parts about Redux and Redux Toolkit that I haven't covered in this tutorial.

Thank you very much for reading my tutorial.

View the final code on GitHub

Pssst...

You know React already? Wanna learn how to use TypeScript with it?

Get this detailed online course and learn TypeScript and how to use it in React applications.

Course thumb image