I've come across this Eric Elliott tweet the other day
Reduce is versatile:
— Eric Elliott (@_ericelliott) April 22, 2017
const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x);
It's a cool demonstration of creative use of Array.prototype.reduce and of ES6's conciseness. But also, it's a technique for generating a very useful tool in function composition, as Eric Elliott also writes:
Function composition is the process of combining two or more functions to produce a new function. Composing functions together is like snapping together a series of pipes for our data to flow through.
A reply tweet was one asking in which situation this pipe
function can be applied. These days, I've been finding more and more use-cases in which function composition techniques come in handy in my day-to-day work, which brought me the idea of writing about a use case I have encountered as it might benefit some, and illustrate how they can apply it to their own work.
I work on an application in the Fintech field. As the web application grew bigger in complexity, user flows for different users have become distinct and can sometimes be unique per-user. Some screens would need to be skipped completely for certain markets, while other screens would show only under certain circumstances.
This business logic was scattered across the components in the app, with each component deciding where to send the user next, possibly determined by certain properties in the app's state (we're using Redux to manage state). In addition, changes in the user flows were being made, with the assumption that more flow changes are on the way in the near future.
A module containing this navigation logic was called for. Several requirements came up at that point:
Let's take a user flow of creating an order. Considering the user flow is affected only by the fields currentOrder
and market
, I was now looking to create a function with this sort of signature:
function getNextCreateOrderFlowView(currentView, { currentOrder, market })
But then for the next flow, the signature would have to be different:
function getNextSomeOtherFlowView(currentView, { someField, anotherField })
More importantly, my views would have to know which data they need pass in, in order to determine what's the next view, violating our 3rd requirement. A more desired function signature would look like this:
function getNextCreateOrderFlowView(currentView, appState)
This way, I can dump the whole app state inside, and my views do not need to bother with circumstances of the navigation. Speaking in Redux terms, my view's state map function can look like this:
function mapStateToTarget(state) {
{
nextView: getNextCreateOrderFlowView(this.viewName, state)
...
}
}
And so, we can rest assured that nextView is always up to date, and all of the logic and state mapping is inside the navigation module. Up next, let's take a look at the flow's implementation.
We've made the assumption that a user flow is an ordered list of views, where each view may or may not have a predicate that decides if the view should be used. We called that list of views a flow definition Under these assumptions, the following structure made sense:
function stepOnePredicate(appState) {
...
}
function stepThreePredicate(appState) {
...
}
const definition = [
{
name: 'stepOne',
predicate: stepOnePredicate,
},
{
name: 'stepTwo',
},
{
name: 'stepThree',
predicate: stepThreePredicate,
},
]
Where the predicates would take the app state and:
We were able to recognize that a predicate is composed of two steps, where step #1 is a common step for all of the flow predicates.
In order to generate the getNext*View
function, we need to compose the flow. For that, we need a flow definition, and a flow mapState
function (I called it a flow configuration). The predicate in the flow would be a pipe of these two functions:
This frees the flow's predicates from knowing anything about the app state. Also, if the store structure changes, only the flow's mapState function will need to be updated. Putting this together, this was the final result:
import createOrderFlowConfiguration from './create-order-flow';
import { pipe } from '../../utils';
function composeFlow({ definition, mapState }) {
// The array of views, the flow definition, is transformed.
// The predicates are replaced with predicates that can accept the whole appState
return definition.map(view => Object.assign({}, view, {
predicate: view.predicate ? pipe(mapState, view.predicate) : () => true,
}));
}
export const createOrderFlow = composeFlow(createOrderFlowConfiguration);
What's left now is implementing a function that would iterate over the flow. For example, it could receive the current view and the app state, iterate over the views until the predicate returns true, and finally return the view.
import { createOrderFlow } from './flows/flows';
function composeGetNextViewFn(flow) {
return (viewName, state) => {
const currentView = flow.find(view => view.name === viewName);
let index = flow.indexOf(currentView) + 1;
let nextView = flow[index];
while (nextView) {
if (nextView.predicate(state)) return nextView.name;
index += 1;
nextView = flow[index];
}
};
}
export const getNextCreateOrderView = composeGetNextViewFn(createOrderFlow);
You will now notice that getNextCreateOrderView
is the result of composeGetNextViewFn(composeFlow(flowConfiguration))
, which could be composed by using pipe
export const composeGetNextViewFnFromFlowConfig = pipe(composeGetNewViewFn, composeFlow);
By writing in a functional style, we can limit the complexity of our code to small, pure, and testable units. By using function composition we can express complex multi-step operations with our building blocks. This keeps our software maintainable and flexible, as small units are easier to write, test, read, debug, and reuse.