DEV Community

Ross Chapman
Ross Chapman

Posted on • Updated on

Let's talk about Orchestration vs Separation of Concerns: React/Redux Edition: Part 2

In Part 1 I examined the failing pragmatism of Separaton of Concerns and began to explore the pitfalls of what we might call the "heavy event handler" anti-pattern; as well as a potential antidote: orchestration of concerns. Let's continue, and even write some code.

Heavy handler is a condition whereby React developers overload component event handlers with too much responsibility. (Though it will easily transpose to any event-driven JS DOM framework). It seems to be a smell that emerges in applications that reach a level of domain scale -- a complexity of happy paths -- that is too high for any one developer or team of developers to fit in their heads. For example: a seemingly simple form submission callback like createOrder() becoming more than a routine CRUD exercise (wrapping up a persistence call with a serialized payload, telling the browser to POST, and refreshing all the data at once). At domain scale there is an excess to handle; an excess that expands the original purpose of a function as the software grows. That single request/response cycle becomes a directed graph of server calls, state mutations, data merging, data querying, and independent renders to be managed. What we often call a transaction script or sequence. That newly created/updated Order is likely implicated in a linked relation to other entities. Pricing sums or ranges may need to be updated elsewhere on the page; perhaps a shipping or tax policy needs to be updated and displayed; UI elements like modals or drawers closed or opened; maybe some parts of the page can be updated first before others; how do you avoid spinner madness, etc...

Why do we overload event handlers?? (deeper dive) πŸŠπŸΌβ€β™€οΈπŸŠπŸ½β€β™‚οΈπŸŠπŸΌβ€β™€οΈ

My best guess is that the close proximity of event handlers to the site of the triggered event -- both physical (nearby in the file) and temporal (this is where things happen next) -- makes this an easy place to reason about where to coordinate the consequent behavior. We are not using jQuery anymore but we still think like jQuery developers; that is to say, temporally; there's a cultural inheritance in the industry that's hard to shake. The easiness of using event handlers is especially attractive if devs are unfamiliar with more advanced options. In this sense easy is akin to how Rich Hickey describes "easy" as "familiar" or "near to our capabilities" (see 3:35):

If a dev is inexperienced or is still learning React's core proposition -- UI = f(State) -- it's a real challenge because these frameworks won't necessarily stop you from thinking too simply about your code; one step at a time, linear (vs graph). React isn't opinionated about how/where you coordinate events and side effects; it's only opinionated about DOM observation and mutation (reconciliation, etc...). Even if you layer on Redux, you're really only given a hook into the action process sequence -- middleware -- to add invariants, do async work, etc.... mapDispatchToProps is still a pretty thin veneer that just grants access to a shared context.

Keeping event handlers light 🦩🦩🦩

I was delighted to come across a Tweet the other day where Kyle Shevlin advocates for more sophisticated orchestration and keeping event handlers "light."

I think he's right. Event handlers should operate as a pass-through. Further down the thread he warns that heavy handlers will cause you to prop dunk application context and branch logic that relate to other components into presentational components; in other words, you'll create the kind of coupling that accelerates entropy; or, as Hickey would say, make your software "complected."

Have you ever worked in an application that had more than one save button on the page without some kind of container, provider, presenter, controller, service, etc...? The struggle is real; not only because there are two buttons -- Publish and Save definitely have a place side by side -- but inevitably you’ll cross streams by trying to manage and thread Boolean flags everywhere. (See Part 1 for more about control objects.)

Push business logic to the edge πŸ‹πŸ½β€β™‚οΈβ›Έβ›Έ

You'll sometimes hear industry experts talk about pushing logic to the edge of your application. This is exactly the right heuristic to help guide developers toward remedies for heavy event handlers. Here is Sandi Metz expounding on this heuristic and widening if further:

What we know is that for these very large applications that don't get a handle on what their app does by putting their code in the middle, and using the framework for the things they shouldn't be writing, it's impossible to know what your app does. How are you going to port that and take advantage of a new language or technology?...I'm really terrified of the framework being in charge of invoking my own code.
Maintainable Podcast, 30:45

I'm suddenly wondering how different our efforts would be migrating RoR and Django templates to SPAs if our business logic wasn't "in the middle" -- deep in the framework and/or near to where user interaction/input is recieved. Perhaps countless dollars and hours saved without such heavy excavation and transportation.

Metz does vital work here expanding our understanding of coupling to the relationship between the business logic and the framework. Not only does a code smell like heavy handlers make the code harder to respond to change in the near-term -- to actually be "reactive" and move at a desired clip -- it forecloses on the chance to make any big decisions about architecture in long-term like migrating frameworks, even replacing a router or form library; or what about porting logic into a cousin framework like React Native if your organization decides to consolidate client development. I don't have experience with the latter, but this reminds me of the potential portability achieved by the indirection codified in unified configs and DSLs; the kinds of which have emerged from CSS-in-JS practices:

Even if you don’t write software for native applications, it’s important to note that having a truly cross-platform component abstraction allows us to target an effectively limitless set of environments, sometimes in ways that you might never have predicted.

A Unified Styling Language, Mark Dalgleish

Code walk-through πŸ‘¨β€πŸ’»πŸšΆπŸ»β€β™€οΈπŸšΆπŸ»β€β™€οΈ

Below is a walk-through of thought process and code snippets that attempt an orchestration of concerns by pushing business logic into a conductor that is built in React; mainly to exploit it's prop passing capabilities; and to play with doing less frameworky things with a framework. The conductor is in part inspired by a recent post by Avdi Grimm; wherein he thinks through a similar code smell of Ruby applications where runaway Service Objects complect code and make deterministic reasoning a chimera chase. Hard bound classes might be the OO symptom for insufficient orchestration in the same way that the callback hell of event handlers is our version in functional-esque land. Grimm says he usually puts transaction scripts in a single module namespaced to the app. I've taken a similar approach: my AppConductor is just a React class component which encapsulates the callback behavior for creating and adding a resource to a collection in memory. Our old reliable todo-like web form example app.

This conductor lives at the "edge" of the app in a couple ways:

  1. Notionally closest to network i/o since it also instantiates and makes network calls through an apiAdapater (which is an indirection layer encapsulated in a POJO).
  2. It's highest in the component graph in order to cover and capture all events and actions for the components below. Of course, this would be more obvious as a single edge among edges in a larger application.

The overall goal was to move action dispatching, side effects, and state mutations under a separate roof so a clear interface is discovered between the stateless display components and the data-y code.

Here's a short demo of the prototype in action. The full code can be played with on Code Sandbox:

App gif

Before we take a look at some of the code let's first revisit the heavy handler code we've been scrutinizing:

// This is oversimplified. The real code for this callback would be a complicated graph  
// of nested asynchronous and synchronous calls. Imagine at the edge of thes thunks each 
// dispatched action mutates state.
let postEntityForm = (e, data) => {
    await dispatch(saveEntity(data));
    let entities = await dispatch(fetchEntities());
    let taxPolicy = await dispatch(maybeFetchEntityTaxPolicy());
    await dispatch(maybeUpdateEntityPriceSuggestions(taxPolicy, entities));
    let isEditing = dispatch(getIsEditingFromState());

    if (isEditing) {
        dispatch(prePopulateForm(data));
    } else {
        dispatch(resetForm());
    }
}

let MyFormComponent = () => {
    return {
        <Form>
            <Button type={'submit'} onClick={postEntityForm}/>
        </Form>
    }
}
Enter fullscreen mode Exit fullscreen mode

One of the first things I did was draw a line in the sand:

let submitEntityForm = (data) => {
  dispatch('SUBMIT_ENTITY_FORM', data);
}

let MyFormComponent = () => {
    return {
        <Form>
            <Button type={'submit'} onClick={submitEntityForm}/>
        </Form>
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the handler is in front of a black box -- a (hopefully) trusted indirection for the code that will do the next things -- and it's scope of responsibility is narrowed so it only acts as a pass-through. For the sake of play, I've put the code for the conductor and it's renderers in a single file; whether the code is colocated or not is a matter of culture.

Of course, the next stage of play is where we can begin to go wild style. How does one tap out the code for those other calls we want to liberate from the "middle" of our app? Well, that depends on what strategy you or your team are comfortable with -- there are many. But if your team is just warming up to the idea of light handlers, then my highly cohesive conductor class might help contour at a glance where an abstraction can be discovered.

AppConductor uses the render child technique as an interface to share props with child components, which are stateless renderers. "Render child" -- does it have a community-blessed name? -- is a strong choice for a central control object because it inverts control for developers. Rather than having to reach back into a control object to futz with layout the developer only receives what is necessary from the conductor and is free to compose the layout themselves. This is also a variation on the Compound Component pattern. If we're adding layout responsibility to control objects it's usually a sign that our boundaries between data and presentation are leaking. It often feels harmless, and the colocation is convenient; though, it may very well just turn out fine when domain scale is small. I tend to prefer stronger separation, but it might be because early in my career I wrote Ember. JSX is a beautiful mess to me.

Here's what a paired down implementation of AppConductor looks like that wraps my Form component and threads down a submit handler:

<AppConductor>
  {({ submitForm }) => {
    return (
      <>
        <Form handleOnSubmit={submitForm} />
      <>
    );
  }}
</AppConductor>
Enter fullscreen mode Exit fullscreen mode

Ultimately the child components will assume an interface that passes event handlers and a getModel function to pull the model on demand -- which, I'm discovering, is an idea I really like. Renderers that pull data is the paradigm of clients generally; it's so related to how we think about the interfaces between our API and the client code; I appreciate the consistency; it forces you to think more about what this component really wants and how/where/when it gets it.

<AppConductor>
  {({ submitForm, getModel }) => (...)}
</AppConductor>
Enter fullscreen mode Exit fullscreen mode

Now let's back out and take a look at the basic structure of AppConductor and how the event capture and data is designed to flow:

class AppConductor extends React.Component {
  userActions = {
    submitForm: "SUBMIT_FORM"
  };

  actionRouter = async (action) => {
    switch (action.type) {
      case "SUBMIT_FORM":
        // wondering where all those calls are gonna go?? 😎
      default:
        throw Error("It should be impossible to get here");
    }
  };

  dispatch = (actionType) => (data) => {
    let action = {
      type: actionType,
      payload: data
    };

    return this.actionRouter(action);
  };

  render() {
    let childProps = {
      submitForm: this.dispatch(this.userActions.submitForm),

    };

    return this.props.children(childProps);
  }
}
Enter fullscreen mode Exit fullscreen mode

If you are familiar with a state management library like Redux, you'll notice some familiar naming and use of switch statements. For one, I've created a small courier/action factory - dispatch - which returns a function that partially-applies an action type argument. When the inner function is invoked from the actionRouter, the emitted SyntheticEvent is curried and wrapped up with the event type into a standard action object format -- I'm thereby maintaining that Redux-y, event sourcing inspired event/command object format which encodes both the type and payload.

If you were thinking it, yes: it would also be perfectly reasonable to pass down the dispatch method as a child prop, but for the moment I was enjoying a more explicit API that pre-defined the possible set of actions. Which I pretty much ran with by defining all possible userActions as an instance property of AppConductor. With an additional layer of typings (all the code is TypeScript'ed btw), you can imagine a really solid contract for other developers. Eg:

type UserAction = "SUBMIT_FORM";
type UserActions = {
  [key: string]: UserAction;
};

class AppConductor extends React.Component<Props, State> {
  readonly userActions: UserActions = {
    submitForm: "SUBMIT_FORM"
  };
  //...
}
Enter fullscreen mode Exit fullscreen mode

The dispatch method is first in line of three sequential function calls that coordinate the form submission script. This ordered sequence of "managers" is designed to coordinate the ordered transactions of side effects and mutations. It's really the coup de grace to our heavy handler; a refactor that became a rewrite; the conductor; visualized as:

dispatch -> actionRouter -> processor
Enter fullscreen mode Exit fullscreen mode

In Part 1 I mentioned "Simple Flow." The above is inspired by a couple guiding principles from that pattern as it was articulated to me by Santiago Ledesma while working at Eventbrite:

  • Actions don't return anything
  • Actions do not set or modify derived data

As well as the advice from Ian Horrocks circa 1999, lest we forget:

The control object maintains the state of the user interface as a whole. When a control object receives a message from a user interface object, the message and the current state of the control object are used to determine the actions that will be executed and possibly update the state information maintained by the control object....

In this flow Actions are merely handled. Lightly, with care. Quickly patched through to an actionRouter -- which will no doubt appear familiar as a kind of reducer -- but really is a middleware. Redux highly discourages effectful calls in your reducer case statements because Redux cannot guarantee a deterministic outcome -- despite it being technically possible since reducers are just normal functions. On the other hand actionRouter welcomes effectful calls.

Nonetheless, I don't just dump my whole transaction script into the router. I want actionRouter to assume the narrow characteristic of a router -- the switch board of the system. Thus I group the effectful operations into a single processor function which is called from the router's case statement. I'm not sure there has to be a 1:1 relationship between router case and processor, but keeping the actionRouter simple does create the opening for logging and other telemetry to live separately from business logic.

Processors do the heavy lifting in my simple flow. This, at long last, is where all that handler callback pyramid scheme ends up. Let's see how the processor works alongside a small finite state machine to express a predictable outcome when a user submits the form:

processBookCreate = async (payload) => {
  // Update component status (sync)
  this.statusMachine(this.statuses.waiting);
  // Post request (async)
  await this.apiAdapater.books.post(action.payload);
  // Update component status (sync)
  this.statusMachine(this.statuses.success);
  // Update model (sync)
  this.model.updateAll("books", books);
  // Update component status (sync)
  this.stateMachine(this.statuses.hasData);
};

statusMachine = (nextStatus: Status) => {
  switch (nextStatus) {
    case this.statuses.waiting:
      if (
        this.status === this.statuses.idle ||
        this.status === this.statuses.hasData ||
        this.status === this.statuses.hasError
      ) {
        return this.setState({ status: nextStatus });
      }
    case this.statuses.hasData:
      if (this.status === this.statuses.success) {
        return this.setState({ status: nextStatus });
      }
    case this.statuses.success:
      if (this.status === this.statuses.waiting) {
        return this.setState({ status: nextStatus });
      }
    default:
      console.error("Logical fallacy achieved!");
  }
};

actionDispatch = async (action) => {
  switch (action.type) {
    case "SUBMIT_FORM":
      console.time("actionManager:SUBMIT_FORM");
      await this.processBookCreate(action.payload);
      console.timeEnd("actionManager:SUBMIT_FORM");
      console.timeLog("actionManager:SUBMIT_FORM");
      break;
    default:
      console.error("It should be impossible to get here");
  }
};
Enter fullscreen mode Exit fullscreen mode

I like separate functions that keep the network calls and state mutations distinct from state computation; it helps you think about what is happening vs what is, and when. It's not necessary -- and not necessarily preferred -- but it suited my mental model while playing around. For example, if after a user adds another Book to their collection and I have more than 0 number of Books in my local store, I may want to fetch and display some suggested titles. Eg:

if (books.ids.length > 0) {
  this.stateMachine(this.statuses.waiting as Status);
  let suggestedBooks = await this.apiAdapater.books.suggest();
  this.stateMachine(this.statuses.success as Status);
  this.model.updateAll("suggestedBooks", suggestedBooks);
}
Enter fullscreen mode Exit fullscreen mode

This is the purview of the processor. Whereas, hypothetically, I might control a special condition of application state in the state machine that checks the application context for network connectivity in order to distinguish between WAITING on i/o vs 'OFFLINE':

 case this.statuses.waiting:
    if (
      this.state.status === this.statuses.idle ||
      this.state.status === this.statuses.hasData ||
      this.state.status === this.statuses.hasError && this.state.navigator === 'online'
    ) {
      return this.setState({ status: nextStatus });
    } else if (
      this.state.status === this.statuses.idle ||
      this.state.status === this.statuses.hasData ||
      this.state.status === this.statuses.hasError && this.state.navigator === 'offline'){
      return this.setState({ status: this.statuses.offline });
    }
Enter fullscreen mode Exit fullscreen mode

I just love love love that JavaScript developers are taking a closer look at state diagrams these days; I've been pulling state sketches and charts into my development process and it has been a boon when working on component integrations that are beyond that critical domain scale. On the tool side I'm a fan of Sketch.Systems, a GUI for designing Harel-flavored state charts.

The state machine I wrote for this playground app executes parts (because WIP) of the following state chart:

Can't you imagine exploring this "code" alongside a designer or product manager? (I mean, let's call it code. It's an artifact of the software construction process. Does it have to be executable to be code?)

Parting thoughts πŸ™‡πŸ½β€β™€οΈπŸ’­πŸ’­

On naming: Grimm's article uses "process" as the verbal prefix for his refactored module method and I wanted to try it on. For some reason we seem to shy away from making the thing we want to do into a noun. Rather than processResourceCreate we often write createResource. I've always found the latter irksome; it's far too ambiguous a semantic gloss for binding a set of side effects that are likely to churn and/or grow over time. That said, I'm cool with using createResource to alias a single i/o operation if another dev is (within reason) capable of deriving it's meaning from the surrounding scope easily enough; eg, I may consider wrapping up apiAdapter.books.post as createBook for export from my api.js module. However, generally speaking if we want to express a container for a transaction script -- which I'm doing here -- a verb like process helps signify a transaction sequence.

Ultimately we might write a library to hide the dirty details of switch and if/else statements. My hope is just that imperative implementations of these indirections on the AppConductor strongly illustrate the benefits of rethinking transaction scripts outside of event handlers. However you design the abstraction -- you may even just reach for something off the shelf -- it's also important to remember that once you're writing the code that performs orchestration work that you're careful to avoid leaking the DOM or user interaction into those functions or classes: quickly pivot on the event/action and encapsulate the transaction script and side effects in distinct managers (apologies for for the continued floor manufacturing analogy -- we need better metaphors!). Other questions to keep in mind:

  • What pieces are portable?
  • How can we easily test those?
  • Are the developers touching this code in full control of the outcome?

Check out the complete -- albeit WIP -- code up on CodeSandbox here: Test Drive Today! 🚘🚘

Oldest comments (0)