Straightening our Backbone: A lesson in event-driven UI development
Mixpanel’s web UI is built out of small pieces. Our Unix-inspired development philosophy favors the integration of lightweight, independent apps and components instead of the monolithic mega-app approach still common in web development. Explicit rather than implicit, direct rather than abstract, simple rather than magical: with these in-house programming ideals, it’s little surprise that we continue to build Single-Page Applications (SPAs) with Backbone.js, the no-nonsense progenitor of many heavier, more opinionated frameworks of recent years.
On an architectural level, the choice to use Backbone encourages classic Model-View designs in which control flow and communication between UI components is channeled through events, without the more opaque declarative abstraction layers of frameworks such as Angular. Backbone’s greatest strengths, however — its simplicity and flexibility — are a double-edged sword: without dictating One True Way to architect an application, the library leaves developers to find their own path. Common patterns and best practices, such as wiring up Views to listen for change events on their Models and re-render themselves, remain closer to suggestions than standard practices, and Backbone apps can descend into anarchy when they grow in scope without careful design decisions.
A problem of communication
Behind Joreteg’s statement lies an unwelcome truth: a thoughtless eventing setup leads to bad control flow issues. In the case of Mixpanel, as some of our front-end reports grew in scope and complexity, patterns which worked at first became unwieldy. Consider the following event/listener/state tangle:
A click on a dropdown (1) makes the component update a UI state object (2). Listeners in both the dropdown and the Model layer register a change in the state object (3) and update themselves. The Model re-fetches data from a server; when the response comes back (asynchronously), it updates the state object with new graph data (4). The graph widget registers this change (5) and consequently updates the state object again after its internal updates (6), with a long chain of potential further repercussions to other listeners. This type of event-driven communication via a global state object (a single model changed by and listened to by all Views and Models as a sort of event bus) may work for very simple UIs, but for more involved interactions with more components and subcomponents produces “event spaghetti” with the typical problems of global variables: non-obvious mutual dependencies and code which is difficult to reason about. The symptoms of this underlying issue show up in the form of circular event loops (A triggers B triggers A triggers B etc.), unexpected double-renders, and subtle race conditions during pageload and bootstrapping an app.
These sorts of issues will not be unfamiliar to anyone who’s worked with many JS apps and frameworks. It’s par for the course with growing client-side apps, and at this point some would jump ship, seeking the clearer waters promised by more “magical” frameworks. As the recent pushback from the React/Flux camp has demonstrated, though, often what you need isn’t a monolithic abstraction layer so much as a simplified and explicit flow of data through your application. For us, the answer wasn’t to dump Backbone in favor of a shinier object, but instead to address the real issues within our apps — issues of our own creation — with a saner architecture.
Rearchitecting the front end
The primary mechanism by which we decoupled our UI components and simplified control flow is a classic pub/sub implementation. Components have no knowledge of the larger context in which they are embedded, but signal actions by emitting events which other objects can listen to independently. A sample interaction flow might look as follows:
In the above case, a user-initiated interaction (selecting an item through a subview) causes the GraphSelector component to emit an event notifying any interested subscribers that an item has been selected. A “mediator” picks up on the event and interacts proactively with other objects: it tells the model layer to fetch fresh data from the server, and informs another UI widget of state changes, which this other widget can use to update itself as necessary (for instance, resetting itself). This pattern is repeatable: any sufficiently large or complex area of an app (such as a single route) may hold its own mediator to encapsulate the details of its UI interactions and communicate with the Model layer with a small, well-defined API.
This flow is in contrast to our previous system, in which the component, rather than signaling its actions, instead directly set values on the UI state object, with other components and models listening for changes (and in turn performing their internal updates and updating the global state object again). The difference can be subtle in practice: in one system the component says, “I have been updated, now I will make the appropriate app state updates” (requires knowledge of the wider system, couples each component to the app-specific state object and thereby to all other components); while in the other, “I have been updated, others may take action if appropriate” (the component knows only its own internals, while app-specific UI logic is centralized in the mediator). They are both event-based, but one relies heavily on global state and handles extra complexity poorly, while the other keeps components decoupled and limited in scope.
On the basis of this standard pub/sub pattern, we derive a simple system for managing data flow through an app with many subviews and events. The main rules of the game are as follows:
- user interactions with View components (menus, buttons, sliders, etc) trigger events
- programmatic UI updates (e.g., setting the value of a dropdown in app code) never trigger events
- Views listen for events only from their children, never from parent views
These basic principles specifically address the control flow problems noted in the previous section, by removing the potential for feedback loops and other unwanted/unexpected chain reactions. In a practical implementation, they lead to several further design characteristics:
- View initialization and render are separate steps
- no events are fired during the app’s initial bootstrap/render process
- on a given UI screen, a single top-level mediator takes on all responsibilities of communication/event-dispatching between subviews
Effectively, what these seemingly abstract rules produce is a straightforward control flow mechanism in which any given UI action or model data update results in simple, predictable, finite execution — similar in its insistence on a unidirectional event flow to the Flux architecture (which others have gone so far as to characterize as a simple rebranding of “old-school procedural programming”).
Within a self-contained route or screen of a report app, the main View (the one instantiated by the app’s router) holds its own subviews and handles all the event-driven communication between them, serving as the mediator in the previous diagram and earning the name “Orchestrator.” In practice, the code of the Orchestrator view becomes the centralized location and source of truth for all inter-widget communication in its purview. Consequently, reading through the Orchestrator code offers a quick high-level overview of all subview interdependencies:
In contrast to a declarative approach which might abstract this setup phase into a data structure or specialized markup extensions (as is common for two-way data-binding), the imperative version above leaves no ambiguity as to when and how binding and updating takes place.
Looking within a subview such as DatePicker, user interaction may lead to internal updates, but ultimately triggers events to signal the action — in a manner which requires no knowledge of anyone else’s internals:
Conversely, when the Orchestrator receives news of an action or state change which should be reflected in the UI of DatePicker, it passes the data explicitly to the subview, which updates itself. In no case does a UI component trigger events as the result of messages from the Orchestrator, eliminating the problem of circular dependencies in the event chain.
An important characteristic of the system’s loose coupling is the fact that subviews (such as DatePicker and GraphSelector) have knowledge neither of each other, nor of the Orchestrator. They could be lifted out of the app and replaced or included as library components with minimal changes. Each subview is responsible only for its own child subviews (e.g., one of several dropdown menus within a complex widget), listening for messages from them as appropriate and ensuring that its overall UI remains in sync with data as given at initialization and updated later via calls to its update method.
With the largely imperative, plain-vanilla Backbone, flow of our restructured reports, there is explicit visibility into every step of the bootstrapping and rendering process. No one-off custom syntax to adhere to, no hidden helpers, no unexpected global state changes deep within a widget’s code. Data flows predictably between the Model and View layers, without the GOTO-like shortcuts offered by a global state object. UI components manage their own state and nothing more, without tight couplings to other app objects. The event bus which is now present, the Orchestrator view, serves as the single, easily-located center for all interactions between components of the overall system.
This is not a framework built on top of Backbone. These are patterns for how our organization uses Backbone, offering us some clearly advantageous separation of concerns, a solid Model layer, some conveniences in eventing and routing, and otherwise getting out of the way — which Backbone does eminently well, in contrast to many other solutions. Front-end development can work wonderfully without the extremes of tooling, libraries, and heavyweight declarative abstraction we sometimes want to saddle on it in search of a panacea. Keeping our control flow simple, and our components and SPAs small and decoupled, works all the magic we need. Addy Osmani got it exactly right: “at the end of the day, the key to building large applications is not to build large applications in the first place.”
Originally published at https://engineering.mixpanel.com on April 8, 2015.