Two Weird Tricks with Redux
I have now used Redux pretty extensively in multiple projects by now, especially the Firefox Developer Tools. While I think it breaks down in a few specific scenarios, generally I think it holds up well in complex apps. Certainly nothing is perfect, and the good news is when you want to do something outside of the normal workflow, it’s not hard.
I rewrote the core of the Firefox debugger to use redux a few months ago. Recently my team was discussing some of the code and it made me remember a few things I did to solve some complex scenarios. I thought I should blog about it.
The best part of Redux is that it keeps its scope small, and even markets itself as a low-level abstraction. Some people find this off-putting; it takes too much code to do various things. But I like the simplicity of the abstraction itself because it’s very straight-forward to debug. Actions pump through the system and state is updated. Logging the actions is almost always enough information for me to figure out why something happened badly.
One of the challenging parts is integrating asynchronous behavior with redux. There are many solutions out there, but you can get very far with the thunk middleware which allows action creators to dispatch multiple actions over time. The idea is that the asynchronous layer sits “on top” of the entire system and pumps actions through it. In this way, you are converting asynchronous events into actions that give you a clear look into what is happening.
But there are some asynchronous situations that simple middlewares aren’t capable of handling. One solution is to throw everything into Observables which would require essentially using an entirely different language. It’s not clear to me how to automatically convert those streams into actions though, and I’m not interested in Observables. Other solutions like sagas look very interesting, but I’m not sure if they would solve all the problems. (EDIT: Note that the primary use case here is using redux in the context of an existing system so the more light-weight the solution, the better.)
While working on the debugger for the Firefox devtools, I ran into two situations that took some thought to solve. The first one exposes a weakness in Redux’s architecture, and the second one wasn’t as much of a weakness but required boilerplate code to solve.
Here are two weird tricks to solve some complex Redux workflows.
Waiting for an Action
Actions are simple objects that are passed into functions to accumulate new state. In an asynchronous world, you have 3 actions indicating asynchronous work: start, done, and error. In our system, they all are of the same type (like ADD_BREAKPOINT
) but the status
field indicates the event type.
We have our own promise middleware to write nice asynchronous workflows (example). Any actions that use the promise middleware return promises, so you can build up workflows like dispatch(addBreakpoint()).then(…)
in other action creators or components. This is common in the redux world; remember, the asynchronous layer sits “on top” of redux, dispatching actions as things happen.
There’s one flaw in all of this though: once an asynchronous operation starts, we don’t have a reference to it anymore. It’s like creating a promise but never storing it. That’s not really a “flaw”, but in a traditional app if you need the promise later you would probably stick it onto a class (this._requestPromise
), but we have nowhere to store it. Only action creators see promises and they are just functions.
Why would you need it later? Imagine this scenario: you have a protocol method eatBurger
that the client can call. Unlike fries which you like to shove as much in your mouth at once, you don’t ever want to be eating more than one burger at once (or do you?). Now, if another burger happens to appear in front of you, you do want to eat it, but you have to wait until you finished your current one.
Because of this, you can’t just request eatBurger
willy-nilly like you would eatFry
(or most other methods). If a current eatBurger
request is going on you want to wait for that one to finish and then send another request. This could either be an optimization (lots of data coming from the server) or avoiding race conditions (the request mutates something).
This inherently involves state. You have to know if another request is going on at some indeterminate time, so that information must be stored somewhere. This is why I think expressing this with any async abstraction is going to be somewhat complex, but some may solve it better than others.
I think it’s an anti-pattern to store promises somewhere and always requiring going through the promise to get the value. This kind of code explodes in complexity, is really hard to follow, and forces every single function in the entire world to be promise-based. The great thing about Redux is that almost everything acts as normal synchronous functions.
So how do we solve this in Redux? We don’t want to store promises. The only other option is to wait for future actions to come through. If we have a flag in the app state indicating an eatBurger
request is currently happening, we can wait for the { type: EAT_BURGER, status: “done” }
action to come through and then re-request a burger to eat.
That means we need a way to tap into the action stream. There is no way to do this by default. So I wrote a middleware: wait-service. I called it a “service” because it’s stateful. This allows me to tap into the action stream by firing an action that holds a predicate and a function to run when that predicate is true. Here’s how the EAT_BURGER
flow would look:
if (state.isEatingBurger) {
dispatch({
type: services.WAIT_UNTIL,
predicate: action => (action.type === constants.EAT_BURGER &&
action.status === "done"),
run: (dispatch, getState, action) => {
dispatch({ type: EAT_BURGER,
condiments: ['jalapenos'] });
}
});
return;
}
// Actually fire a request to eat a burger
This simple service provides us a way to bypass Redux’s normal constraints and implement complex workflows. It’s a little awkward, but it’s rare that you need this. I’ve used it a couple times to solve complex scenarios, and I get to keep the simplicity of Redux for the rest of the system.
Ignoring Async Responses
While using the developer tools, you might navigate away to a different page. When this happens, we need to clear all of the state and rebuild it for the new page. We basically want to forget everything about the previous session.
Redux makes this very nice because we just need to replace the app state with the initial state, and the entire UI resets to the default. It’s great.
But there’s a problem: there may be async requests still happening when you navigate away, like fetching sources, searching for a string, etc. If we reset the state and handle the response, it will blow up because it doesn’t have the right state to handle the response with.
This was a huge problem with tests: the test would run and shutdown quickly, but we would still handle async responses after the shutdown happened. It blows up, of course, and makes the test fail. But the infuriating part if that these failures are intermittent: it would only sometimes shutdown too quickly.
This is a problem inherent in testing, but luckily Redux gives us the tools to come up with a nice generic solution.
Actions are broadcasted to all reducers, and async actions all have the same shape (a status
field and also a seqId
field which is unique identifier). Because of this, we track exactly which async requests are currently happening. It’s just another reducer. This reducer looks for async actions and keeps an array if ids of currently open requests. I’ll just put all the code here:
const initialState = [];
function update(state = initialState, action) {
const { seqId } = action;
if (action.type === constants.UNLOAD) {
return initialState;
}
else if (seqId) {
if (action.status === 'start') {
return [...state, seqId];
}
else if (action.status === 'error' || action.status === 'done') {
return state.filter(id => id !== seqId);
}
}
return state;
}
Remember, when a page navigates away or the devtools is destroyed we reset the app state to the initial state. That clears out this reducer’s state, so it’s just an empty array.
Because of this, when we create the store we can use a top-level reducer that ignores all async requests that we don’t know about. (See all the code)
let store = createStore((state, action) => {
if (action.seqId &&
(action.status === 'done' || action.status === 'error') &&
state &&
state.asyncRequests.indexOf(action.seqId) === -1) {
return state;
}
return reducer(state, action);
});
By not dispatching actions, we completely ignore any async response that we don’t know about. Implementing this simple strategy completely solved all of the shutdown-related intermittents I faced.
End
I’m sure that you will show me other abstractions that handle these things better by default. In my opinion, every abstraction has rough edges. The question is how well you can workaround the system to solve any rough edges you face, and I don’t feel like these are bad solutions. It’s just code you have to write. I’ve certainly faced much harder situations where the abstraction was just too controlling and I couldn’t even work around it.