Middleware
Middleware is a powerful concept in Reflex that allows you to enhance your producer and actions with additional functionality.
- 🧩 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:
- TypeScript
- Luau
const middleware: ProducerMiddleware = (producer) => {
return (nextAction, name) => {
return (...args) => nextAction(...args);
};
};
local middleware: Reflex.Middleware = function(producer)
return function(nextAction, name)
return function(...)
return nextAction(...)
end
end
end
You can separate the middleware into three layers:
- The producer
- The middleware itself is called once when it is initially applied to a producer.
- 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.
- This is called once per action when the middleware is applied, and receives a
- 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.
- This is called in place of the action during a dispatch, and can call
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:
- TypeScript
- Luau
const loggerMiddleware: ProducerMiddleware = (producer) => {
producer.subscribe((state) => {
print("next state:", state);
});
return (nextAction, name) => {
return (...args) => nextAction(...args);
};
};
local loggerMiddleware: Reflex.Middleware = function(producer)
producer:subscribe(function(state)
print("next state:", state)
end)
return function(nextAction, name)
return function(...)
return nextAction(...)
end
end
end
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:
- TypeScript
- Luau
const loggerMiddleware: ProducerMiddleware = (producer) => {
producer.subscribe((state) => {
print("next state:", state);
});
return (nextAction, name) => {
return (...args) => {
print(`dispatching ${name}:`, ...args);
return nextAction(...args);
};
};
};
local loggerMiddleware: Reflex.Middleware = function(producer)
producer:subscribe(function(state)
print("next state:", state)
end)
return function(nextAction, name)
return function(...)
print("dispatching " .. name .. ":", ...)
return nextAction(...)
end
end
end
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
:
- TypeScript
- Luau
const counter = createProducer(0, {
set: (state, value: number) => value,
increment: (state, amount: number) => state + amount,
});
counter.applyMiddleware(loggerMiddleware);
counter.set(5);
counter.increment(1);
local counter = Reflex.createProducer(0, {
set = function(state, value: number)
return value
end,
increment = function(state, amount: number)
return state + amount
end,
})
counter:applyMiddleware(loggerMiddleware)
counter.set(5)
counter.increment(1)
# 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:
- TypeScript
- Luau
const crashReporterMiddleware: ProducerMiddleware = (producer) => {
return (nextAction, name) => {
return (...args) => {
try {
return nextAction(...args);
} catch (error) {
warn(`caught error in ${name}:`, error);
return producer.getState();
}
};
};
};
local crashReporterMiddleware: Reflex.Middleware = function(producer)
return function(action, name)
return function(...)
local success, result = pcall(action, ...)
if not success then
warn("caught error in " .. name .. ":", result)
return producer:getState()
end
return result
end
end
end
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:
- TypeScript
- Luau
const cancelMiddleware: ProducerMiddleware = (producer) => {
return (nextAction, name) => {
if (name !== "increment") {
return nextAction;
}
return (...args) => {
print("cancelled increment!");
return producer.getState();
};
};
};
local cancelMiddleware: Reflex.Middleware = function(producer)
return function(nextAction, name)
if name ~= "increment" then
return nextAction
end
return function(...)
print("cancelled increment!")
return producer:getState()
end
end
end
Now, if we apply this middleware to our producer, we can see that the increment
action is cancelled:
- TypeScript
- Luau
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());
local counter = Reflex.createProducer(0, {
set = function(state, value: number)
return value
end,
increment = function(state, amount: number)
return state + amount
end,
})
counter:applyMiddleware(cancelMiddleware)
counter.set(5)
counter.increment(1)
print(counter:getState())
# cancelled increment!
# 5
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:
- TypeScript
- Luau
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);
local counter = Reflex.createProducer(0, {
set = function(state, value: number)
return value
end,
increment = function(state, amount: number)
return state + amount
end,
})
counter:applyMiddleware(cancelMiddleware, loggerMiddleware)
counter.set(5)
counter.increment(1)
# 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:
- TypeScript
- Luau
counter.applyMiddleware(loggerMiddleware, cancelMiddleware);
counter.increment(1);
counter:applyMiddleware(loggerMiddleware, cancelMiddleware)
counter.increment(1)
counter.increment(1)
=> logger(1)
=> cancel(1) // 🔴 Cancelled
=> increment(1)
# 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:
- TypeScript
- Luau
const doubleMiddleware: ProducerMiddleware = (producer) => {
return (nextAction, name) => {
if (name !== "increment") {
return nextAction;
}
return (amount: number) => {
return nextAction(amount * 2);
};
};
};
local doubleMiddleware: Reflex.Middleware = function(producer)
return function(nextAction, name)
if name ~= "increment" then
return nextAction
end
return function(amount: number)
return nextAction(amount * 2)
end
end
end
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:
- TypeScript
- Luau
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);
local counter = Reflex.createProducer(0, {
set = function(state, value: number)
return value
end,
increment = function(state, amount: number)
return state + amount
end,
})
counter:applyMiddleware(doubleMiddleware, loggerMiddleware)
counter.set(5)
counter.increment(1)
producer.increment(1)
=> double(1)
=> logger(1 * 2)
=> increment(2)
# 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:
- TypeScript
- Luau
producer.applyMiddleware(loggerMiddleware, doubleMiddleware);
producer.set(5);
producer.increment(1);
producer:applyMiddleware(loggerMiddleware, doubleMiddleware)
producer.set(5)
producer.increment(1)
producer.increment(1)
=> logger(1)
=> double(1)
=> increment(1 * 2)
# 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;
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:
loggerAction
is called first, and invokescancelAction
with the resultcancelAction
is called next, and invokesdoubleAction
next if it wasn't cancelleddoubleAction
is called last, and calls theincrement
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.