Skip to main content

Middleware

Middleware is a powerful concept in Reflex that allows you to enhance your producer and actions with additional functionality.

what you'll learn
  • 🧩 What middleware is
  • 🔍 The structure of middleware
  • 🔌 How to use middleware
  • 📝 How to write your own middleware
  • 📚 Common middleware

What is middleware?

In state management libraries, middleware provides a way to insert code in between the dispatch of an action, and the moment the action is called. It is often used to log actions, perform asynchronous tasks, or add additional data to the action.


Using middleware

Reflex middleware has three layers of control that enable you to customize the behavior of your producers in powerful ways. A basic middleware function looks like this:

const middleware: ProducerMiddleware = (producer) => {
return (nextAction, name) => {
return (...args) => nextAction(...args);
};
};

You can separate the middleware into three layers:

  1. The producer
    • The middleware itself is called once when it is initially applied to a producer.
  2. The action wrapper
    • This is called once per action when the middleware is applied, and receives a nextAction function and the name of the action.
    • It returns a function that can run additional code before or after the next middleware or action runs.
  3. The dispatcher
    • This is called in place of the action during a dispatch, and can call nextAction to run the action.
    • If middleware is applied after this one, nextAction invokes the next middleware.

But this can be difficult to visualize, so let's try to build our own middleware.

Logging actions

You may have learned how to subscribe to state changes in a previous guide, but that covered running side effects when state changes, not when actions are dispatched. What if you want to log the actions themselves as they happen?

We can create a middleware that logs actions as they are dispatched. Let's start with the first layer of middleware, which is called when the middleware is applied to a producer. We'll use this layer to subscribe to state changes, so we can log how actions change the state:

const loggerMiddleware: ProducerMiddleware = (producer) => {
producer.subscribe((state) => {
print("next state:", state);
});

return (nextAction, name) => {
return (...args) => nextAction(...args);
};
};

The next action wrapper is called once per action when the middleware is applied, and returns a function that essentially replaces the action in the producer. We'll use this to modify an action to log the action name and arguments before continuing with the dispatch:

const loggerMiddleware: ProducerMiddleware = (producer) => {
producer.subscribe((state) => {
print("next state:", state);
});

return (nextAction, name) => {
return (...args) => {
print(`dispatching ${name}:`, ...args);
return nextAction(...args);
};
};
};

And that's it! We've built a middleware that logs actions as they are dispatched. We can apply it to our producer with applyMiddleware:

const counter = createProducer(0, {
set: (state, value: number) => value,
increment: (state, amount: number) => state + amount,
});

counter.applyMiddleware(loggerMiddleware);
counter.set(5);
counter.increment(1);
Output
# dispatching set: 5
# dispatching increment: 1
# next state: 6

Now, we can see the actions as they are dispatched, and how they change the state.

Reflex comes with a built-in loggerMiddleware that does exactly this, so you don't have to write it yourself.

Crash reporting

Another common use case for middleware is crash reporting. You can use middleware to catch errors in your actions, and report them as they happen:

const crashReporterMiddleware: ProducerMiddleware = (producer) => {
return (nextAction, name) => {
return (...args) => {
try {
return nextAction(...args);
} catch (error) {
warn(`caught error in ${name}:`, error);
return producer.getState();
}
};
};
};

If an error is thrown by an action or a later middleware, the crash reporter will catch it and log it, then return the current state. This prevents the error from propagating to the rest of the game.

Cancelling actions

You can also use middleware to cancel actions. If you don't want an action to be dispatched, you can return the current state instead of calling the next action:

const cancelMiddleware: ProducerMiddleware = (producer) => {
return (nextAction, name) => {
if (name !== "increment") {
return nextAction;
}

return (...args) => {
print("cancelled increment!");
return producer.getState();
};
};
};

Now, if we apply this middleware to our producer, we can see that the increment action is cancelled:

const counter = createProducer(0, {
set: (state, value: number) => value,
increment: (state, amount: number) => state + amount,
});

counter.applyMiddleware(cancelMiddleware);
counter.set(5);
counter.increment(1);
print(counter.getState());
Output
# cancelled increment!
# 5
caution

Cancelling an action also prevents the middleware after cancelMiddleware from being called! This means that if you want some middleware to never get cancelled by cancelMiddleware, pass it to applyMiddleware before it.

Using multiple middlewares

You can apply multiple middlewares to a producer, and they will be called in the order they are applied. This means that the first middleware will be called with your initial arguments, and the last middleware will invoke the original action.

Let's try applying both the logger and cancel middlewares to our producer:

const counter = createProducer(0, {
set: (state, value: number) => value,
increment: (state, amount: number) => state + amount,
});

counter.applyMiddleware(cancelMiddleware, loggerMiddleware);
counter.set(5);
counter.increment(1);
Output
# dispatching set: 5
# cancelled increment!
# next state: 5

The set action set the state to 5, and the increment action was cancelled, so the final state was determined to be 5.

But wait, why didn't the logger middleware print the increment action? That's because the order of middleware matters!


Ordering middleware

The order in which you apply middleware is important. If the first middleware cancels an action, the rest of the middleware will not be called. This means that middleware that you want to always run should be applied first, and middleware that you want to run last should be applied last.

Cancel actions to cancel middleware

In the last section, we applied a cancel middleware and a logger middleware to our producer. The call chain looked like this:

counter.increment(1)
=> cancel(1) // 🔴 Cancelled
=> logger(1)
=> increment(1)

If we also want to log cancelled actions, we need to apply the logger middleware before the cancel middleware:

counter.applyMiddleware(loggerMiddleware, cancelMiddleware);
counter.increment(1);
counter.increment(1)
=> logger(1)
=> cancel(1) // 🔴 Cancelled
=> increment(1)
Output
# dispatching increment: 1
# cancelled increment!

Transforming arguments

We can also insert a middleware that modifies the action's arguments! Let's say we want to double the amount that the counter is incremented by:

const doubleMiddleware: ProducerMiddleware = (producer) => {
return (nextAction, name) => {
if (name !== "increment") {
return nextAction;
}

return (amount: number) => {
return nextAction(amount * 2);
};
};
};

This middleware will double the amount that the counter is incremented by, but only if the action is an increment action. If it's named anything else, it will skip the middleware and call the next one in the chain.

Let's apply this middleware to our producer, and see what happens:

const counter = createProducer(0, {
set: (state, value: number) => value,
increment: (state, amount: number) => state + amount,
});

counter.applyMiddleware(doubleMiddleware, loggerMiddleware);
counter.set(5);
counter.increment(1);
producer.increment(1)
=> double(1)
=> logger(1 * 2)
=> increment(2)
Output
# dispatching set: 5
# dispatching increment: 2
# next state: 7

As you can see, the increment amount was doubled because double called logger with the doubled amount, and the logger middleware received doubled amount.

However, if we apply the logger middleware before the double middleware, we get a different output:

producer.applyMiddleware(loggerMiddleware, doubleMiddleware);
producer.set(5);
producer.increment(1);
producer.increment(1)
=> logger(1)
=> double(1)
=> increment(1 * 2)
Output
# dispatching set: 5
# dispatching increment: 1
# next state: 7

As you can see, the next state is still 7 even though the logger said the count was incremented by 1. This is because the logger middleware received the number 1 before the double middleware could transform it in the chain, so it logged the original amount.

This is why the order in which you apply middleware is important. If you have a middleware that modifies the action or its results, and you want another middleware to work with the initial data, you should apply the second middleware before the first one, so it gets called first.


How does it look internally?

If you're curious about how Reflex applies middleware, here's a technical look at how the middlewares from the ordering middleware section is applied to the increment action:

producer.applyMiddleware(loggerMiddleware, cancelMiddleware, doubleMiddleware);
// The middleware is called and returns an action wrapper
const doubleWrapper = doubleMiddleware(counter);
const cancelWrapper = cancelMiddleware(counter);
const loggerWrapper = loggerMiddleware(counter);

// The action wrapper is called, where 'nextAction' is the next
// action or middleware in the chain
counter.increment = doubleWrapper(counter.increment, "increment");
counter.increment = cancelWrapper(counter.increment, "increment");
counter.increment = loggerWrapper(counter.increment, "increment");

// Calling this action goes through the middleware chain first:
// => logger(1)
// => cancel(1)
// => double(1)
// => increment(1 * 2)
counter.increment = doubleIncrement;
why are middlewares called in reverse?

The nextAction argument passed to the action wrapper is the next action or middleware in the chain, but we can only know the next action by calling its action wrapper first. By composing the middleware functions in reverse, we can make sure that:

  1. loggerAction is called first, and invokes cancelAction with the result
  2. cancelAction is called next, and invokes doubleAction next if it wasn't cancelled
  3. doubleAction is called last, and calls the increment action with the doubled amount

You can imagine the chain being composed like this:

loggerWrapper(cancelWrapper(doubleWrapper(counter.increment)));

Summary

  • Middleware is used to run code in between an action dispatch and the moment it updates the state.
  • Call applyMiddleware on a producer to apply middleware to it.
  • You can use middleware to cancel an action or transform its input or output.
  • Cancelling an action will also prevent any middleware after it from running.
  • The order in which you apply middleware is important.

Read more on middleware →