How to decouple state and UI (a.k.a. you don’t need componentWillMount)
Strategies for dealing with routing, data fetching, authentication and workflow testing without the UI layer
Managing an open source project like MobX gives me the opportunity to peek in many code bases. While doing so, I discovered that most React applications are not driven by the state that is stored in stores; they are also driven by the logic in mounting components. That might sound a bit vague so let’s look at two examples:
- Interpreting route changes is often done in components; especially when using react-router. The router constructs a component tree based on the current URL, which fires of componentWillMount handlers that will interpret parameters and update the state accordingly.
- Data fetching is often triggered by the fact that a component is about to be rendered, and kicked off by the componentWillMount lifecycle hook.
Although this approach is very workable, it has some downsides: First, we cannot reason about our application state and flow by looking at the state of our stores alone. Secondly, we need to know which components will be rendered for certain routes and what activities they perform when being mounted. To put that in a picture:
That doesn’t look like our UI is a function of our state. It looks more like our state is initially function of our components. In this blog, I will show how we can inverse this relationship to the following image:
This approach has better decoupling of state and UI. This has a few advantages:
- The complete application flow can be tested without ever needing to instantiate a component.
- More components can be dumb; they don’t have to fetch data or process routing.
- Our stores become more like a state machine, making it easy to follow the transitions of our application.
A sample application
To demonstrate this, we will build a simple document viewer application. The application itself is trivial, but features routing, authentication and data fetching to give it some similarity with real applications. The application consists of two pages with the following properties:
- There is a document overview (route “document/”)
- There is a document view page (route “document/<document-id>”)
- The overview can be accessed by anyone, but to be able to see the the contents of a specific document, one has to be logged in.
This animation shows the entire feature set of the application. Note the changes in the URL bar, loading and error messages.
The application is built using React and MobX. Nonetheless, I think the principles shown can be applied to, or already be found in many existing frameworks. The application is initially generated using the mobx-react yeoman generator. The full source code can be found in this github repo.
ViewStore: captures the application state
The majority of the application state will be captured in the ViewStore. It captures two important pieces of data: the currentUser and the currentView.
currentUser reflects the identity of the logged in user. currentView describes which page is currently visible and captures the data needed for that page specifically.
Now we can introduce our first action, which updates the currentView to reflect that we are showing the overview of documents.
First of all, our store accepts fetch as a constructor parameter. This is basically a tiny wrapper around the window.fetch API. It automatically parses JSON and bails out on any non-200 response. We pass it explicitly to our store so that we can easily stub it in our unit tests.
The showOverview method is the interesting part, it updates the currentView
and starts fetching documents from our HTTP server (a dumb static file server in this demo). The promise returned by fetch is passed to fromPromise from the mobx-utils package which turns the promise into a MobX observable. This allows MobX to observe the progress of the promise, both in the UI and during our tests.
Testing the documents overview
At this point, the ViewStore captures the current state or “route” of our application and fetches the necessary data. Since there is no browser specific stuff in the ViewStore we can easily test this server-side using node and tape. In our tests, we stub fetch with a simple file system call (I am well aware that there are awesome libraries that will do this better, but I want to keep this simple and transparent for the sake of this blog).
Note that we use MobX’s when to wait until the observable promise has settled and then check whether the correct number of documents have been loaded.
Showing a specific document
Time for a more challenging action; opening a specific document. Remember, only logged in users are allowed to do this.
To check whether a user has logged in, we introduce a computed property that checks if the currentUser is set. The showDocument function is very similar to showOverview, except that this time we immediately reject the promise if the user is not logged in, instead of fetching the data.
Note that we still update the currentView. The advantage of this is that we can already update the URL (see below) and reflect in the UI the intended location of the user.
Storing the observable promise inside the currentView has a nice benefit:
it eliminates race conditions. The observable promise that is visible to the outside world as currentView.document is always the one created by the last showDocument call.
To support a scenario where the user actually logs in, the ViewStore exposes the performLogin action which takes a username, password and callback. The callback will be invoked with true on a successful login, and false otherwise.
(Yes, I do realize this is the most horrible way to perform a login ;-). A decent login call would add some noise to the example but not fundamentally change the approach itself)
With authentication in place, we can introduce two additional tests to check whether we can(not) access documents with(out) logging in. Similar to the previous test, we invoke the actions that transition our store to the correct state and we check whether all promises will settle correctly.
Pretty neat so far, right? We have tested the entire workflow, and there is no single component or routing library involved yet. The ViewStore is completely agnostic of those things.
To the DOM!
Time to render our ViewStore! Since all our logic is captured in the ViewStore, most components can be dumb, stateless components.
First there is the ‘App’ component that takes the store and initializes the proper view based on the name of the store’s currentView.name using a simple switch statement. In addition, it shows the currently logged in user.
The component for rendering the document overview is pretty similar to the one that renders a single document. The latter is more interesting so we omit the sources of the DocumentOverview for now. (For simplicity all components are put in the same file in this example).
The Document component switches on the state of the view.document observable promise which was created by the showDocument action.
Based on the state of the promise it renders either a loading message, error message or the resolved document.
But before rendering the document, we check if the current user is authenticated. If that isn’t the case, we render the login form and provide an afterLogin callback. If we didn’t check this, the user would see the authentication error of the rejected document promise. Which is not very user friendly.
To be able to see the “loading” message you can enable network throttling in the chrome devtools:
The login form
The login form itself is pretty straight forward as well. It has some local observable state storing the current username, password and feedback message. The afterLogin is invoked once the user has logged in successfully, which is done through the ViewStore.performLogin action.
Routing: translate route to state
We have not implemented routing yet. As stated earlier, we want the router to invoke actions directly on our stores. Rather than indirectly by constructing a component tree and firing componentWillMount hooks. This makes testing and reasoning simpler, as the UI is purely based on the state that lives in our store.
With director it is pretty simple to setup the routing. We define two routes that trigger either the showDocument or showOverview actions. The latter is used as the default route as well. Note that the startRouter function lives outside our store as it is largely a browser-only thing.
Routing: translate state to route
That was pretty straightforward. Whenever a URL is entered in the browser’s address bar the store will transition to the correct state and the correct UI is rendered. However, the inverse process is missing. If we click a document in the overview, the URL in the address bar should be updated.
One could simply fix this by calling history.pushState in the appropriate actions of the store. That would work, but hold your horses. That approach has two downsides. First, it would make our store browser aware. Secondly, it is a tedious, imperative approach to the problem. If your UI has many possible views, you would end up with pushState calls in a lot of places.
Consider this: the URL of the application is just a representation of the state of our application. Like the UI, it can be derived completely from the current application state. Deriving things from state, that is where MobX excels. So let’s introduce a computed property in the viewStore that derives the path representing the current view:
The currentPath is an abstract string representation of our current state. But it is still just a value. We need a side effect to push it onto the history. So we set up an autorun (which can be used to automatically trigger side effects) in the earlier defined startRouter to take care of that.
To avoid the URL update triggering an URL change and an endless loop, we added a simple guard to check whether the URL has actually changed before pushing a history item. Our routing flow now looks like this:
Connecting the dots
We now have a universal ViewStore that captures the state of our application, React components that render the state, and a function that sets up routing. We can now simply glue those parts together to have a working application instead of just unit tests:
So here we are, we’ve just build an application that does routing, data fetching and authentication. Although it is only ~200 LOC, it’s a good representation of how to implement those concepts in real applications.
We now have a high degree of decoupling between managing the application state (which is completely testable on it’s own), rendering the UI and routing. This is achieved by (1) removing the responsibility of data fetching and route interpretation from the componentWillMount handlers of our React components and (2) making sure that the global state of the UI is managed by the ViewStore. This makes both testing the components and testing the application flow simpler.
I hope this gave you a nice overview on how one could setup the project structure in a MobX application (or other frameworks) by clearly delineating the state and the things that can be derived from it. If you want to learn more about MobX make sure to check the free egghead.io course!
To summarize, the sources:
Supplementary notes answering potential questions :)
You might have noticed that I didn’t put all of state in the viewStore. Only the state that is relevant for different parts of the application, or that should be persisted while navigating etc.
I don’t have the opinion that one must remove all componentWillMount hooks. I just want to express that one should use them with care. Avoiding them makes the application flow more clear and components simpler.
Don’t get me wrong. React-router is an awesome tool. React-router is probably the most used router lib in combination with MobX and there is no reason to throw it all overboard if you already have these things in place.
The observation in our projects was that there is often a component in the root of the component tree which sole purpose is to update the state on route changes. We noted that different routes often used this very same component as route handler. Which raised the question whether one actually needs the component tree to be constructed when just changing the state directly would have yielded the very same result.
Yes, using decorators is completely optional in MobX.