Why we chose MobX over Redux for Spectacle Editor
For Spectacle Editor, our current collaboration with Plot.ly, we decided to use MobX to handle application state instead of Redux. Redux is an amazing framework, and here at Formidable we continue to use it on new and existing client projects with great results. In part, the decision to use MobX was driven by a desire to test a new approach to React app state, learn new patterns, and challenge our assumptions. Spectacle Editor also has more flexibility in terms of architectural direction: it is not server rendered; there’s virtually no routing, and as an Electron app, it is only targeting one browser. That said, there are some clear reasons why MobX makes sense for an open source presentation editor. ## Short learning curve MobX has a very small API surface area and requires minimal boilerplate. This makes it easy to onboard new developers and have them be productive quickly. The small boilerplate footprint creates code that is both explicit and simple to follow. The core concepts in Spectacle Editor are observable values, computed values, observer, transactions, and autorun. MobX recently announced support for actions, but we’re currently having components call methods directly on the single store instance for simplicity. As the application grows to more stores, we will probably leverage actions to keep code organized. MobX has excellent documentation, but I’ll illustrate some of our use cases with its API below. ### Observables The observable
function/decorator in MobX turns a property into a publisher so that other pieces of the app can subscribe to changes. The main observable of Spectacle Editor is the history of the array of slides that a user is editing. Side note: this history is immutable thanks to seamless-immutable. This enables undo and redo functionality. Redux has an excellent guide on application history and the implementation in MobX for Spectacle Editor is fairly similar. History in Spectacle editor looks like this: ```js export default class SlidesStore { // Observable history array @observable history = Immutable.from([{ currentSlideIndex: 0, slides: [{ // Default first slide }] }]) // Start state at the first entry in history @observable historyIndex = 0; } ``` Updates to observables are done by updating the value. This allows for explicit store code that reads as the author intended. ```js addToHistory(snapshot) { this.history = this.history.concat([Immutable.from(snapshot)]); this.historyIndex += 1; } ``` ### Computed values Computed values subscribe to changes in observables and update their output accordingly. From the MobX docs, “Use @computed
if you have a value that can be derived in a pure manner from other observables”. Computed values themselves are also observables, so you can build computed values that are based entirely on other comupted values. In practice, we ended up with minimal observables and many computed values. The resulting computed values are clear and easy to reason about. With history, we can derive if undo and redo are disabled depending on the length of the history array and current history index. Current state is computed as the item in history at history index. ```js @computed get undoDisabled() { return this.historyIndex === 0 || this.history.length <= 1; } @computed get redoDisabled() { return this.historyIndex >= this.history.length - 1; } @computed get currentState() { return this.history[this.historyIndex]; } ``` ### Observer The observer
function/decorator is the counterpart to observable
and runs when a subscribed-to observable changes. This is one of the few pieces of MobX that is explicitly tied to React and takes a React component class as its only argument (note: it even lives in a separate npm package mobx-react
). MobX handles rerendering the component only when the specific observables and computed values that the component depends on changes. The MobX docs have a great guide on optimizing for performance. In Spectacle Editor, any component that accesses store data directly is wrapped in an observer decorator. ### Autorun autorun
is similar to computed
and observer
in that it runs every time an observable dependency changes. It’s different in that it is specifically for creating side effects. autorun
is useful for the cases where you’re not computing a new observable or rerendering a component but instead, creating side effects. This is useful in testing, logging, persistence, and UI updates that don’t map directly to react. In Spectacle Editor, there’s an interaction with react-motion where an animation delay is required so local state can’t directly follow store state. I expect we’ll uncover more cases where autorun is useful as we build other complex UI features. Be aware that autorun
will call the callback immediately when calling autorun. For instance, with DOM interaction side-effects put the autorun
statement in componentDidMount
to be safe.
Note about magical binding
Computed values, observer classes, and autorun functions are slightly magical in the way that merely accessing an observable within their scope subscribes them to that observable’s changes. That said, the benefits of readable, clean, and performant code are worth the hidden complexity in my opinion. The MobX source code is worth reading if you’re interested in what’s happening under the hood. ### Transactions transaction
is a method that batches any updates to observables. Subscribers to the observables changes in the transaction are only notified once all code within the transaction has completed. This is useful when updating more than one observable because it prevents unnecessary render/compute cycles. With transactions, our addToHistory
method only notifies subscribers once changes to history
and historyIndex
are both finished. ```js // NOTE: Cap history array length to some number to prevent absurd memory leaks addToHistory(snapshot) { // Only notify observers once all expressions have completed transaction(() => { // If we have a future and we do an action, remove the future. if (this.historyIndex < this.history.length - 1) { this.history = this.history.slice(0, this.historyIndex + 1); } this.history = this.history.concat([Immutable.from(snapshot)]); this.historyIndex += 1; }); } ``` ## Powerful primitives MobX’s API is easy to grasp while still allowing for complex state update behavior. It feels very natural to explicitly set the state desired based on input and let MobX handle the updates to subscribers. With the above setup for observable history and computed values for undoDisabled
, redoDisabled
, and currentState
, it’s simple to create undo and redo functions. ```js undo() { // double check we're not trying to undo without history if (this.historyIndex === 0) { return; } this.historyIndex -= 1; } redo() { // Double check we've got a future to redo to if (this.historyIndex > this.history.length - 1) { return; } this.historyIndex += 1; } ``` ## Testing Testing the interaction between stores and React components can be tricky. MobX makes this straightforward with autorun, which allows us to effectively mock consumers of state changes. Autorun matches the observer function/decorator so we can be sure that if autorun is/isn’t running with the correct values, the observer components will too. To follow with the history example, here’s how we test that the addToHistory transaction only notifies observers once per addToHistory call and that the state is correct. ```js const testStore = new Store(); // Set initial state testStore.history = Immutable.from([["a"]]); testStore.historyIndex = 0; const historySpy = spy(); const historyIndexSpy = spy(); // Autorun invokes the callback immediatly and adds listeners // for all observables in the callback scope. const disposer = autorun(() => { historySpy(testStore.history); historyIndexSpy(testStore.historyIndex); }); // Verify that autorun invoked the spy once on initialization expect(historySpy.callCount).to.equal(1); expect(historyIndexSpy.callCount).to.equal(1); // Verify that autorun was called with the initial values // args[0][0] gets the first call then the first entry in arguments array. expect(historySpy.args[0][0]).to.eql(Immutable.from([["a"]])); expect(historyIndexSpy.args[0][0]).to.eql(0); // Call our function that should trigger autorun testStore.addToHistory(["b"]); // Verify that transaction only reran autorun once. expect(historySpy.callCount).to.equal(2); expect(historyIndexSpy.callCount).to.equal(2); // Verify the values passed to autorun were the expected values // args[1][0] gets the second call then the first entry in arguments array. expect(historySpy.args[1][0]).to.eql(Immutable.from([["a"], ["b"]])); expect(historyIndexSpy.args[1][0]).to.eql(1); // Remove autorun. This belongs in an after/afterEach statement. disposer(); ``` ## Less boilerplate, more application code. With the API outlined above, we’ve created a store whose state representation is clearly outlined in code. Within the store, MobX’s footprint is limited to the initial @observable, @computed, and the occasional transaction. All of which add clarity to the behavior of the code. For components, we borrowed the provider pattern from redux and pass the store down on context. Any component that depends on store state has an @observer decorator. Components only access the pieces of state they rely on and MobX handles the rerenders when those specific pieces change. There is very little plumbing yet the app is flexible and easy to reason about. ## Conclusion Spectacle Editor is far from a representative sample of current React apps. It doesn’t require server rendering, cross browser compatibility, or complex state changes based on routing. This project had flexibility in architecture and we are using the opportunity to expand our knowledge and share these findings with the community. The decision to use MobX over Redux was not taken lightly and this post is not meant to replace due diligence based on project requirements. MobX allows us to write clean, testable, and maintainable code for this project. So far, there haven’t been any snags with MobX getting in the way of functionality or limiting UI capabilities. The API is small but powerful, testing is straightforward, and boilerplate code is virtually non-existent. Like React, MobX is a framework with the right level of abstraction that allows for complex code behavior while still letting application code take center stage.