A Use Case of Function Composition

I've come across this Eric Elliott tweet the other day

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.

#The Problem

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.

#Requirements

A module containing this navigation logic was called for. Several requirements came up at that point:

  1. The ability to define, examine, and change user flows in one place.
  2. The navigation module should be stateless, and given app state, would be able to iterate over the user flow.
  3. Views should be able to figure out what is the next view in the user flow, considering the circumstances (app state), but without being concerned about which of those affects the flow.
  4. It should be possible to go backwards or forwards, or run through the whole flow.

#Plan for solution

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.

#Flow 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:

  1. Map the whole app state to state that is relevant to the flow,
  2. Run the logic on that mapped state.

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.

#Composition opportunity

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:

  1. The flow's mapState
  2. The predicate that accepts mappedState (from the flow definition)

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);

#Summary

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.