Skip to content
Devin Schulz
Menu
The archive

Redux, Testing, React, Jest

Combining Synchronous Actions Using Redux Thunk

Learn how to use Redux Thunk for handling several synchronous actions at once to modify different areas of the application state.

Archived writing, published in 2018. I’ve fixed some grammar since, but otherwise it’s as I wrote it, so some details may have aged.

Simple frontend applications I’ve worked with have one event (click, keypress, input change, etc.) that dispatches a single action to modify part of the application state tree. As your application grows in complexity, that single event may need to perform several actions at once and run some sort of business logic before they are dispatched.

A Potential Solution

Recently I’ve been using Redux Thunk for dispatching a single thunk action, and in turn, it dispatches several actions to modify independent areas of the application state. Think Redux Thunk is only for asynchronous actions? Think again! Never heard of Redux Thunk or the term thunk? No worries, I will go a bit more in-depth about how it works below.

Now let’s pretend you have an e-commerce store, and your application state looks something like this:

 {
  cart: [],
  view: {
    sidebarOpen: false
  },
  merchandise: [
    {
      id: 1,
      price: 24.99,
      type: 'shirt'
    }
  ]
}

Imagine you have a sidebar that slides out from the side of the screen whenever a user clicks a button to add an item to the cart. When doing so, you might dispatch two separate actions: one for adding the item to the cart, and the second for telling the view that the sidebar should be opened.

// actions.js
const openSidebar = () => ({
  type: "OPEN_SIDEBAR",
});

const addToCart = (id) => ({
  type: "ADD_TO_CART",
  payload: id,
});
// AddToCart.jsx
const AddToCart = ({ addToCart, id, openSidebar }) => (
  <button
    onClick={() => {
      addToCart(id);
      openSidebar();
    }}
  >
    Add to cart
  </button>
);

This button component would generally be nested deep within the application hierarchy. In the future, you might want to have this same functionality in a completely separate component. This would be problematic since you now have two areas of your application performing the same logic.

You can see how this defeats the DRY principle of software development. Instead of dispatching several actions from within a component, try separating them into a thunk action.

Here I’ll update the button to fire a single action, which also gives you the benefit of one less property to pass down.

// AddToCart.jsx
const AddToCart = ({ addItemToCart, id }) => (
  <button onClick={() => addItemToCart(id)}>Add to cart</button>
);

Building a Thunk Action

// actions.js
const addItemToCart = (id) => (dispatch, getState) => {
  dispatch(addToCart(id));
  dispatch(openSidebar());
};

Let’s try to break this down. The thunk action addItemToCart is a function that accepts the cart ID as an argument and then returns another function. When you dispatch any action, the Thunk middleware will check if the current action type is a function and, if it is, call it and pass the Redux dispatch and getState as the arguments.

This is a pretty simple example, so let’s complicate it a little. You now need to save the item to the backend to allow the cart to persist between sessions.

// actions.js
const saveCartRequest = (id) => ({
  type: "SAVE_CART_REQUEST",
  payload: id,
});

const addItemToCart = (id) => (dispatch) => {
  dispatch(addToCart(id));
  dispatch(openSidebar());
  dispatch(saveCartRequest(id));
};

Did you know that you can trigger other thunk actions from within a thunk action? You are not limited to only dispatching actions that have a return value.

Now instead of only passing the ID to addToCart, we’ll pretend the reducer requires the entire item to be sent instead. With these new requirements, you can call getState to get the merchandise from the store and find the value you need to send.

// actions.js
const addItemToCart = (id) => (dispatch, getState) => {
  const { merchandise } = getState();
  const item = merchandise.find((item) => item.id === id);
  dispatch(addToCart(item));
  dispatch(openSidebar());
  dispatch(saveCartRequest(id));
};

// Can't forget to update the `addToCart` action to use
// the `item` instead of the `id`
const addToCart = (item) => ({
  type: "ADD_TO_CART",
  payload: item,
});

Testing a Thunk Action

Testing thunk actions is a little bit different from testing regular actions. The main difference is that we’re no longer testing the returned value of an action, but whether dispatch is called with the correct values.

// test.actions.js
const setup = () => {
  const cart = [];
  const merchandise = [
    {
      id: 5,
      type: "shirt",
    },
  ];
  const store = { merchandise, cart };
  const getState = () => store;
  return {
    dispatch: jest.fn(),
    getState,
  };
};

describe("actions", () => {
  describe("addItemToCart", () => {
    it("should dispatch all the correct actions", () => {
      const id = 5;
      // 1: Get the dispatch spy and mocked state
      const { dispatch, getState } = setup();

      // 2: Call the thunk action
      addItemToCart(id)(dispatch, getState);

      // 3: Test that the dispatch was called with the
      //    correct arguments
      expect(dispatch).toHaveBeenCalledTimes(3);
      expect(dispatch).toHaveBeenCalledWith({
        type: "ADD_TO_CART",
        payload: {
          id: 5,
          type: "shirt",
        },
      });
      expect(dispatch).toHaveBeenCalledWith({
        type: "OPEN_SIDEBAR",
      });
      expect(dispatch).toHaveBeenLastCalledWith({
        type: "SAVE_CART_REQUEST",
        payload: id,
      });
    });
  });
});

Conclusion

What I love the most about this approach is that all the logic is contained within a specific area of the application. It lives entirely outside your components, which helps keep them “dumb.” Luckily, thunk actions are super simple to test, which gives you no reason not to test them. Whether you use this approach now or later, it can handle your more challenging workflows.

Versions

This article has been written and updated to support the following versions:

  • Redux: 4.0.0
  • Redux Thunk: 2.3.0
  • Jest: 23.2.0

Additional Resources