Introducing redux-operations

In my last post, I mentioned a new package that I wrote with Dror Tirosh. I showed how it could easily solve the most insanely difficult redux problems (dynamic state, action watching, working on intermediate state, etc.) while also giving you a pretty GraphiQL-esque API so your new employees (and forgetful veterans) can learn your frontend as easily as they can your GraphQL backend.

However, much like that friend that lets you use his shower without showing you how to turn it on, I didn’t talk about how it worked or how you could use it yourself.

Showers. Making engineers feel like idiots since 1767.

Now that the API is small and stable, I figured it’s time to explain the idea behind redux-operations and how you can use it — all without the pain of generators, middleware, and all-or-nothing solutions.

How redux works

Before we dive in, it’s important to have a more-than-basic understanding of how redux works.

When an action is dispatched, it runs through every reducer function. If two reducers have the same action type (e.g. INCREMENT), then the execution order is arbitrary because iterating through the keys of an object (i.e. the rootReducer) is not guaranteed to be consistent across browsers.

Furthermore, reducers are not aware of each other, meaning there’s no way a reducer knows if the state it is given is the original pre-action state, or an intermediary state adjusted by the action in another reducer (e.g. if Reducer1.INCREMENT changes the state, Reducer2.INCREMENT receives the new state without knowing if Reducer1.INCREMENT did anything).

Core concepts of redux-operations

Concept #1: One action has a sequence of operations

Using the above as an example, we might want to think of INCREMENT as a series of predictable events, or operations:

const INCREMENT = [Reducer1.INCREMENT, Reducer2.INCREMENT];

By achieving this, we can:

  • Assign a priority to each operation so they occur in the order we want
  • Show the sequence in redux-devtools to make debugging easy
  • Pass along the oldState and state to the following operation
  • Only trigger the particular action instead of running through every reducer
  • Subscribe to an action (e.g. count the number of times INCREMENT was called across all components)

Concept #2: Dynamic state and lazy calculation

When a redux store is created, it runs through an INIT action triggering every reducer to grab the initial state. This is possible because your state is static and defined by the object keys passed into combineReducers. But what if state is determined at runtime?

With redux-operations, it uses static state whenever possible. When it sees a dynamic state, it doesn’t create the state until it’s needed, usually called in mapStateToProps. A common example might be a form: need to add another telephone field? Just tell it to create state.telephones.number2. Each substate will reuse the same reducer, keeping your code concise.

Concept #3: Document your work

I can’t say enough good things about GraphQL’s GraphiQL schema explorer. If you forget what the query name is, or what arguments it takes, or what those arguments should look like, it gives you everything in a nice little side panel. It’s enough to make frontend folks jealous of our backend brethren.

That’s why redux-operations optionally allows you to expand your reducer. For example, seeing the array of operations of a particular action is as easy as looking in redux-devtools:

Notice how the action shows an array of all the operations, sorted by priority. The name of the operation refers to its reducer, which is how actions can listen in on each other. Under SET_COUNTER you can also see all the arguments that it takes, so you know you better stick a number in action.payload.newValue.

If you’d like to learn more about how it does this internally, feel free to check out the heavily commented code.

How to use redux-operations

Now that you know the principles behind redux-0perations, we can get into implementation. We’ll tackle dynamic state, action listening, using intermediary results, and async actions. To follow along, you can get set up by following the readme and see the real-world code in the counter-example.

Dynamic state: Using 1 reducer for infinite state bits

Dynamic state is easy. All you need is the location in the state to store the info and the name of the operation (i.e. the name of the reducer as passed into combineReducers).

Most commonly, you’ll pass in the locationInState from the parent component. For example, if you’re mapping over an array, your mapping function might look like this:

renderCounter = counterIdx => {
return <Counter location={['counters', counterIdx]} key=.../>;
};

Then, that Counter component will use that location as a map and walk your state tree to get there. If it’s empty (all new dynamic states are), it’ll use your reducer to initialize the state:

import {walkState} from 'redux-operations';
import {counterReducer} from './counterReducer';
const mapStateToProps = (state, props) => {
return {
counter: walkState(props.location, state, counterReducer)
}
};

Finally, triggering actions is just as easy because redux-operations offers up a helping HOF to do the hard work for you. Just pass in your locationInState, reducerObject, and all your actionCreators, and it’ll handle all that stuff behind the scenes so you’ve still got a nice clean action creator.

import actionCreators from './actionCreators';
import {counterReducer} from './counterReducer';
import {bindOperationToActionCreators} from 'redux-operations';
const {increment} = bindOperationToActionCreators(location, counterReducer, actionCreators);
...
<button onClick={() => dispatch(increment())}>+</button>

Action listeners: Snooping in on other actions

Sticking with the counter example, let’s assume we’ve got a bunch of em. Then, the brilliant folks in marketing tell you it’s critical that we know how many times the INCREMENT button was clicked across all counters. You may be tempted to do something like calling a CALCULATE_SUM after the INCREMENT is called, and then of course you’ll have to write a new middleware to climb the whole state tree and look for all those dynamic instances…

There has to be a better way!

Instead, it’s pretty darn simple with redux-operations. Just write a reducer that shares the same action type:

import {operationReducerFactory} from 'redux-operations';
const initialState = 0;
export const clickReducer = operationReducerFactory('clickCounter', initialState, {
INCREMENT: {
resolve: (state, action) => {
return state + 1;
},
description: 'Number of times all counters were incremented'
}
});

Since this is the first special reducer we’ve seen, let’s go through it. Instead of a function that uses switch/case, you feed a factory function the name (same as what you put in combineReducers), an initial state, and an object full of action types. The factory function does some nice things like adding an escape hatch for standard reducers. By doing that, redux-operations plays nicely with the rest of the redux ecosystem. You can use it just for the tricky parts of your app; no need for a major overhaul. Now just grab the value from state.clickCounter and you’re done!

Intermediary calculations: solving the waitFor() conundrum

Finally, let’s assume we’ve got a calculation that depends on another piece of the state. In the previous example, we just add 1 anytime we see an INCREMENT. It doesn’t matter if the operation occurs a before or after since there is no dependency issue.

Now, let’s say we want to take that newly incremented counter value, that newly incremented clickCounter value, & multiply them together. Just like last time, the answer is as easy as a 10 line reducer:

export const multiplyAll = operationReducerFactory('multiplyAll', 0, {
INCREMENT_COUNTER: {
priority: 100,
resolve: (state, action) => {
const {counter, clickCounter} = action.meta.operations.results
return counter.state * clickCounter.state;
},
description: 'counters clicked * value of last counter clicked'
}
});

Could you imagine doing this with generators and middleware? I can’t.

Behind the scenes, redux-operations modifies your action and hands you a results meta object that contains the oldState and state of each previous operation. That means you don’t have to sit and cry when marketing tells you “if the old counter value changed by 5 and the number of clicks is now 10, then pop up a modal with a coupon”. You can whip it up in 5 minutes and dare them to stump you again (because they live for that stuff, right?).

Asynchronous actions, right where you want ‘em

Learning how to do async actions in redux has a learning curve. First, you gotta add some middleware to handle thunks or promises, then you gotta call a second action, which is usually in the action creator of the first action. Ultimately, that action creator gets pretty large since it also has to handle the fetched data & make a decision on what action to call next. Instead, we move it into the operation:

INCREMENT_ASYNC: {
resolve: (state, action)=> {
setTimeout(()=> {
const {dispatch, locationInState} = action.meta.operations;
const inc = bindOperationToActionCreators(...);
dispatch(inc());
}, 1000);
return state;
}
}

As seen, the state is returned synchronously, but the async trigger is also dispatched thanks to redux-operations handing dispatch to the action metadata. It works just as easily with promises, too.

Note: For the purists in the crowd, you might argue that an operation is written inside a resolve function which is inside a “reducer”, and redux principle #3 stipulates that reducers must be pure functions, ergo we’re condoning heresy. To those fine folks, I’d say this ain’t your momma’s reducer… or even a reducer at all, since it doesn’t even reduce; we just kept the name “reducer” because it’s what people know. In other words, if the reducer is a bag of Skittles, redux-operations tears it open, grabs the candy, throws the “reducer” wrapper away, and then picks out all the red ones and eats them together (Honestly, if colors are actions and skittles are operations, the analogy is scary accurate).

Closing Remarks

After using this for about a month, I have yet to find a state-wrangling challenge that I couldn’t solve easily with redux-operations. And maybe I’m biased, but it makes my code really concise and easy to reason through. To see how it compares to other advanced solutions, check out the latest challenge at scalable-frontend-with-elm-or-redux. Even if you’re not on a big team and you don’t use redux for anything tricky, that visual API is such a treat for debugging that it’ll leave a big stupid grin on your face. And after all, if you’re not having fun, you’re doing it wrong.