CircleCI’s recently open-sourced frontend is built in ClojureScript using Om. Combining Clojure’s functional primitives and React’s programming model yields a uniquely powerful approach to user interfaces. Previously complex features, such as efficient undo, become trivially simple to implement. The simple versions turn out to be even more powerful. You don’t just get efficient undo, you also gain the ability to serialize the entire state of your application to inspect, debug, or reload! While the promise of snapshotting app state has been part of Om’s story from the beginning, we’ve been working hard to take the concept the rest of the way from idea to reality.
New Benefits, New Costs
As with any new technique, there are kinks to be worked out. Snapshotting with Om requires eschewing the use of component-local state. CircleCI’s frontend has been intentionally designed with this constraint in mind, painstakingly arranging that all components store their state in a global data structure. In return for this effort, we get powerful debugging tools: the ability to save and restore complete application states. But at what cost? Component-local state is an important feature of React.js for enabling local reasoning about individual components. Components hammering on a shared app state data structure can take on much of the same flavor as global variables. Luckily, we can recover benefits of React’s local-state design without sacrificing our powerful new global debugging tools. Read on to find out how.
Component Local State
React famously diffs virtual DOMs to produce a minimal batched set of mutations to the real browser DOM, which it treats as an “external” service. It is less widely understood that, conceptually, React also applies a set of mutations to another external service: the component state map. This state map is a global mutable dictionary which maintains the illusion of component-local state. Just as we say a component is “mounted” onto the browser’s DOM, its state is also mounted onto the state map. Similarly, when a component is unmounted from the browser DOM, its state must be unmounted from the state map. Our goal is to mount component state into our global app state, rather than React’s private state map. To do that, we must understand more about how React works.
When a React render function returns a DOM tree, those nodes represent potentially stateful components. In order to associate the returned stateless tree with the stateful backing store, we need to join the two datasets by component IDs. To alleviate the programmer burden of manually allocating and managing IDs, React computes them from paths through the component tree. Consider this example DOM and corresponding state map:
Each node in the DOM is labeled with a child index and a component type. The nodes of type “Bar” are also labeled with a discriminating key (A, B, and C). Assuming that only the Bar type has component-local state, our state table on the right maps component IDs (paths) to their private state maps. By default, the child index is used in paths, but the programmer can override that with keys, which enable path stability when children come and go, or simply get re-ordered. Imagine a list of todo tasks with server-provided task-ids as explicit keys: re-ordering tasks should not confuse their completion state. For example, let’s re-render without the middle Bar node.
The coloring represents a computed diff: The “Bar:B” node with ID “/1/B” and its corresponding state table entry have been removed. Notice that “Bar:C” has been renumbered, but the explicit key means the “/1/C” ID is stable. Unlike traditional object-oriented widget libraries, we can re-render from the root and rely on framework machinery to manage the state map for us. Adding a new stateful child works similarly, but component types can provide default state values for when an ID is newly added to the state map. From this perspective, traditional addChild and removeChild methods are tantamount to manual memory management with malloc and free! If we abandon React’s component local state, we’re subject to the burden of manual ID management in addition to the typical problems associated with uncoordinated global variables.
From Instrumentation to Intercession
Encapsulation of component state enables useful local reasoning about individual components. Unfortunately, encapsulation often thwarts global concerns such as introspection and instrumentation. Many of the Om’s cool tricks, including snapshot debugging, rely on unified external representations of application state.
React.js rightfully encapsulates state for both the local reasoning benefits and to protect the invariants of their diff engine. The React Developer Tools, like all good debugging tools, violate that encapsulation, letting you peek inside otherwise closed components. Instrumenting objects to observe encapsulated data is usually harmless, but changing encapsulated data is particularly risky. We Clojure programmers know that we can do better with immutable values. It’s impossible for aliasing a state value to run afoul of the diff engine’s invariants: Immutable values don’t vary! This means it’s safe for us to mount component local state in to our global app state.
Armed with immutable values and a desire for strong debugging tools, Om’s creator David Nolen writes about Taking Off The Blindfold. Om provides aspect-oriented hooks for instrumenting every component. What David didn’t mention, is that his hooks enable intercession as well as instrumentation. Intercession enables us to globally augment the behavior of all components by intercepting all operations on their interfaces, both public and private. By overriding get-state and set-state!, we can reuse React’s ID management and abstract state interface, but we can redirect the state values into our global application state!
State Intercession in Action
Here’s what this looks like in the CircleCI frontend:
By interceding on all requests between the React diff engine and its state service, we effectively substitute our own state service. Our state service simply mounts the component local state onto the application state database, which is just a value stored in a normal Clojure atom. The CircleCI frontend implements this state intercession in the frontend.state-graft namespace. As a result, we can have our component state encapsulation cake and eat the global state value too.
Snapshots in the style of the Om undo demonstration has been immensely useful in development and debugging. We can save and restore application states for faster iteration and reproduction of bugs. This capability was previously at odds with component local state. Now that component local state is mounted onto the global application state, we can rely on these debugging tools universally without sacrificing the isolated reasoning benefits of component local state.
This technique works out a kink in Om’s model. And the Circle team has worked out the kinks in the implementation of this solution. David Nolen is committed to delivering these improvements to all Om users in the near future. Stay tuned to his Twitter feed for updates on that front.
Thanks to Sean Grove, David Nolan, and Cheng Lou for reviewing drafts of this.