17. The Middleware Chain

Video Link

In our last lesson, we wrote two functions that wrap the dispatch function to add custom behavior. Let's take a closer look at how they work together.

const configureStore = () => {
  const store = createStore(todoApp);

  if (process.env.NODE_ENV !== 'production') {
    store.dispatch = addLoggingToDispatch(store);
  }

  store.dispatch = addPromiseSupportToDispatch(store);

  return store;
};

The final version of the dispatch function before returning the store is the result of calling addPromiseSupportToDispatch.

addPromiseSupportToDispatch Before:

const addPromiseSupportToDispatch = (store) => {
  const rawDispatch = store.dispatch;
  return (action) => {
    if (typeof action.then === 'function') {
      return action.then(rawDispatch);
    }
    return rawDispatch(action);
  };
};

The function returned by addPromiseSupportToDispatch acts like a normal dispatch function, but if it gets a Promise it waits for it to resolve, and then passes the result to rawDispatch (where rawDispatch is the previous value of store.dispatch).

For non-promises, the function calls rawDispatch right away. raw Dispatch corresponds to store.dispatch at the time addPromiseSupportToDispatch was called.

Refactoring our dispatch-enhancing Functions

Since store.dispatch was reassigned earlier (inside of configureStore), it's not completely fair to refer to it as rawDispatch inside of addPromiseSupportToDispatch.

We'll rename rawDispatch to next, because this is the next dispatch function in the chain.

addPromiseSupportToDispatch After:

const addPromiseSupportToDispatch = (store) => {
  const next = store.dispatch;
  return (action) => {
    if (typeof action.then === 'function') {
      return action.then(next);
    }
    return next(action);
  };
};

Above, next refers to the store.dispatch that was returned from addLoggingToDispatch().

Recall that our addLoggingToDispatch() function also returns a function with the same API as the original dispatch function, but it logs the action type, the previous state, the action, and the next state along the way.

It calls rawDispatch which corresponds to store.dispatch at the time that addLoggingToDispatch was called. In this case, this is the store.dispatch provided by createStore() inside of configureStore.

However, it is entirely conceivable that we might want to override the dispatch function before adding the logging.

For consistency, we will rename rawDispatch to next here as well. In this particular case, next points to the original store.dispatch.

addLoggingToDispatch()

const addLoggingToDispatch = (store) => {
  const next = store.dispatch;
  if (!console.group) {
    return next;
  }

  return (action) => {
    console.group(action.type);
    console.log('%c prev state', 'color: gray', store.getState());
    console.log('%c action', 'color: blue', action);

    const returnValue = next(action);

    console.log('%c next state', 'color: green', store.getState());
    console.groupEnd(action.type);
    return returnValue;
  };
};

Introducing Middleware Functions

While this method of extending the store works, it's not really great that we override the public API and replace it with custom functions.

To get away from this pattern, we will declare an array of middleware functions, which is just a fancy name for the extra-functionality functions we wrote.

This middlewares array will contain functions to be applied later as a single step.

We'll push addLoggingToDispatch and addPromiseSupportToDispatch to the middleware array.

Now we create a function wrapDispatchWithMiddlewares() that takes the store as the first argument, and the array of middlewares as the second.

Refactoring configureStore

const configureStore = () => {
  const store = createStore(todoApp);
  const middlewares = [promise];

  if (process.env.NODE_ENV !== 'production') {
    middlewares.push(logger);
  }

  wrapDispatchWithMiddlewares(store, middlewares);

  return store;
};

Inside of wrapDispatchWithMiddlewares() we're going to use middlewares's forEach method to run some code for every middleware.

Specifically, we will override the store.dispatch function to point to the result of calling the middleware with the store as an argument.

const wrapDispatchWithMiddlewares = (store, middlewares) =>
  middlewares.forEach(middleware =>
    store.dispatch = middleware(store);
  );

Recall that inside of our middleware functions themselves, there is a certain pattern that we have to repeat. We grabbing the value of store.dispatch and store it in a variable called next that we call later.

To make it a part of the middleware contract, we can make next an outside argument, just like the store before it and the action after it.

Updating addLoggingToDispatch()

const addLoggingToDispatch = (store) => {
  return (next) => {
    if (!console.group) {
      return next;
    }

    return (action) => {
      console.group(action.type);
      console.log('%c prev state', 'color: gray', store.getState());
      console.log('%c action', 'color: blue', action);

      const returnValue = next(action);

      console.log('%c next state', 'color: green', store.getState());
      console.groupEnd(action.type);
      return returnValue;
    };
  }
};

With this change, the middleware becomes a function that returns a function that returns a function.

This pattern is called currying. This is not very common in JavaScript, but is actually very common in functional programming languages.

Updating addPromiseSupportToDispatch:

const addPromiseSupportToDispatch = (store) => {
  return (next) => {
    return (action) => {
      if (typeof action.then === 'function') {
        return action.then(next);
      }
      return next(action);
    };
  }
};

Again, rather than take the next middleware from the store, we will make it injectable as an argument so that the function that calls the middlewares can choose which middleware to pass.

Finally, since store is not the only injected argument, we also need to inject the next middleware, which is the previous value of store.dispatch.

Updating wrapDispatchWithMiddlewares

const wrapDispatchWithMiddlewares = (store, middlewares) =>
  middlewares.slice().reverse().forEach(middleware => {
    store.dispatch = middleware(store)(store.dispatch);
  });

Now that middlewares are a first-class concept, we can rename addLoggingToDispatch to just logger, and rename addPromiseSupportToDispatch to promise.

Arrow-ifying our promise Middleware

The curried style of function declaration can get very hard to read. Luckily we can use arrow functions and rely on the fact that they can have expressions as their bodies.

// Before
const addPromiseSupportToDispatch = (store) => {
  return (next) => {
    return (action) => {
      if (typeof action.then === 'function') {
        return action.then(next);
      }
      return next(action);
    };
  }
};

// After
const promise = (store) => (next) => (action) => {
  if (typeof action.then === 'function') {
    return action.then(next);
  }
  return next(action);
}

It is still a function that returns a function returning a function, but it's much easier to read.

The mental model you can use for this is "this is just a function with several arguments that are applied as they become available".

Wrapping up Middlewares

Our middlewares are currently specified in the order in which the dispatch function is overridden, but it would be more natural to specify the order in which the action propagates through the middlewares.

We will change our middleware declaration to specify them in the order in which the action travels through them:

const configureStore = () => {
  const store = createStore(todoApp);
  const middlewares = [promise];
  .
  .
  .

We will also wrapDispatchWithMiddlewares from right to left by cloning the past array then reversing it.

const wrapDispatchWithMiddlewares = (store, middlewares) =>
  middlewares.slice().reverse().forEach(middleware => {
    store.dispatch = middleware(store)(store.dispatch);
  });

Recap at 5:42 in video

results matching ""

    No results matching ""