Skip to content

Instantly share code, notes, and snippets.

@alexreardon
Last active September 14, 2022 17:28
Show Gist options
  • Save alexreardon/58745a5a9072d47e2e0b to your computer and use it in GitHub Desktop.
Save alexreardon/58745a5a9072d47e2e0b to your computer and use it in GitHub Desktop.

The 'middleware listener' pattern:
better asynchronous actions in Redux

For reference, here is a standard synchronous Redux action creator:

export const simpleAction = () => {
    return {
        type: actionTypes.PERFORM_SIMPLE_ACTION
    };
}

Things to note:

  1. it does not know anything about how the action is performed
  2. it is up to some other part of the application to determine what to do with this action

Existing async pattern

Redux documentation on async actions

This is a stripped down example from the Redux documentation:

import * as actionTypes from './action-types';

export const fetchPosts = () => {
    // thunk middleware - allow action creators to return functions
    return (dispatch) => {
        dispatch({
            type: actionTypes.FETCH_POSTS_REQUEST
        });

        dataService.getPosts()
            .then(posts => {
                dispatch({
                    type: actionTypes.FETCH_POSTS_SUCCESS,
                    posts
                });
            })
            .catch(() => {
                dispatch({
                    type: actionTypes.FETCH_POSTS_ERROR
                });
            });
    };
};

Problems

  1. The 'action creator' becomes the 'action coordinator'

    Unlike normal actions, this pattern breaks the idea that the action create function creates an action description. The action creator fetchPosts actually coordinates a lot of the action

  2. This action must always call dataService.getPosts()

    For this action creator to work dataService.getPosts() need to exist and behave in a particular way. This becomes tricky when you want to:

    • have a different dataService for a different consumption points. For example a mockDataService

    This raises the problem of getting the correct dataService dependency into the file. Conditional dependencies is a way to solve this problem but it not ideal.

    • not do anything with the action

    What if a consumer of one of our components does not have a dataService and wants to do something different, or nothing at all with the action?

  3. No application level control over the dataService

    The action creator fetchPosts does not have access to the store state. It cannot make a decision to not call dataService.getPosts() based on the current state. The responsibility for this would be up to the caller of the action creator.

Middleware async pattern

What would it look like if we used middleware to help us decompose things?

Our action creator becomes beautiful again:

// action-creators.js
export fetchPosts = () => {
    return {
        type: actionTypes.FETCH_POSTS_REQUEST
    };
};

Middleware

// middleware.js
import * as actionTypes from './action-types';

const fetchPostsMiddleware = store => next => action => {
    // let the action go to the reducers
    next(action);

    if (action.type !== actionTypes.FETCH_POSTS_REQUEST) {
        return;
    }

    // do some async action
    setTimeout(() => {
        // sort of lame because it will actually call
        // this middleware again
        store.dispatch({
            type: actionTypes.FETCH_POSTS_SUCCESS,
            posts: [1, 2, 3]
        });
    });
};

Good things about this approach

  • you can have multiple middleware for the same action type, or none to ignore it.
  • removing responsibility for coordinating the action away from the action creator fetchPosts

Bad things

  1. Gives too much power to something that is just responding to actions

    In middleware you can:

    • dispatch new actions
    • control the flow of data to the reducers
    • perform other powerful tasks

    This is a lot of power and responsibility given to something that previously had very little power. Notice that the middleware needs to call next(action);. This releases the action to the reducers. This would be something that is easy to forget and could cause a lot of pain.

  2. Recursive dispatch can be confusing

    store.dispatch({
        type: actionTypes.FETCH_POSTS_SUCCESS,
        posts: [1, 2, 3]
    });

    This dispatch would fire a new action that would hit the fetchPostsMiddleware again! It would not dispatch a second action because of this check:

    if (action.type !== actionTypes.FETCH_POSTS_REQUEST) {
        return;
    }

    However, it is possible to create infinite loops if you are not careful (I have!). It can also add additional cognitive load to trying to understand how your application works; where as the original pattern is quite simple to reason about.

  3. Middleware is synchronous

    If you did some processing before calling next(action); you would be adding overhead to every action that goes through your application.

The 'middleware listener' async pattern

Taking the power of middleware without the danger

We still have a beautiful action creator

// action-creators.js
export fetchPosts = () => {
    return {
        type: actionTypes.FETCH_POSTS_REQUEST
    };
};

A 'listener' definition

// data-service-listener.js
import * as actionTypes from './action-types';
import dataService from './data-service';

export default {
    [actionTypes.FETCH_POSTS_REQUEST]: (action, dispatch, state) => {

        // in this listener we get some posts
        // but we could do anything we want
        dataService.getPosts()
            .then(posts => {
                dispatch({
                    type: actionTypes.FETCH_POSTS_SUCCESS,
                    posts
                });
            })
            .catch(() => {
                dispatch({
                    type: actionTypes.FETCH_POSTS_ERROR
                });
            });
    }
    // could add other types to this listener if you like
};

New middleware to handle listeners

// listener-middleware.js
export default (...listeners) => store => next => action => {
    // listeners are provided with a picture of the world
    // before the action is applied
    const preActionState = store.getState();

    // release the action to reducers before firing
    // additional actions
    next(action);

    // always async
    setTimeout(() => {
        // can have multiple listeners listening
        // against the same action.type
        listeners.forEach(listener => {
            if (listener[action.type]) {
                listener[action.type](action, store.dispatch, preActionState);
            }
        });
    });
};

Constructing a store

// store.js
import listenerMiddleware from './listener-middleware';
import dataServiceListener from './data-service-listener';

import reducer from './reducer';
import loggerMiddleware from './logger-middleware';


const store = createStore(
  reducer,
  applyMiddleware(
    // add other middleware as normal
    loggerMiddleware,
    // add as many listeners as you like!
    listenerMiddleware(dataServiceListener)
  )
);

Issues addressed with using middleware

Gives too much power to something that is just responding to actions

  • Listeners do not need to know anything about next(action); or the flow of the application.
  • Listeners get information and they can do stuff with it only through dispatch.
  • Listeners are only called when they are relevant by using action.TYPE as a key (eg [actionTypes.FETCH_POSTS_REQUEST])

Recursive dispatch can be confusing

Listener does not get called recursively (unless you dispatch an action with a matching key of course)

Middleware is synchronous

We made a strong stance that listeners should always be async. This avoids them blocking the reducers and render of your application. Doing this makes it harder for a listener to get itself into trouble.

Conclusion

The 'middleware listener' pattern is a powerful way to take coordination logic out of action creators. It allows different applications to decide what they want to do with particular actions rather than letting the action decide. We already do this with reducers - why not do it with async actions as well!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment