How We Write Front-end Code
Writing front-end code in a sufficiently complex web app has never been an easy task. With all the view, state management, and routing libraries out there, it can be hard to know how best to fit the pieces together. Through writing many thousands of lines of code for the Heap web app, we’ve found that certain principles have allowed us to move fast and ship powerful features without compromising on testability or extensibility. We’d like to share what we’ve learned with the hope that applying these principles helps you do the same.
Interested in learning more about Heap Engineering? Meet our team to get a feel for what it’s like to work at Heap!
Front-end Tech Stack
When Heap was started in 2013, CoffeeScript and Backbone were popular options that allowed us to become productive quickly. Over time, we realized we preferred the type safety guarantees provided by TypeScript, and the iteration speed offered by React. While we still have some Backbone in our codebase, most new features are written in TypeScript and React.
Like many web apps, Heap’s domain models (like events, reports, and dashboards) need to be stored, retrieved, and passed around to different components. We handle this state management with a library called MobX. We use multiple domain stores, each of which is responsible for storing their own domain model data. In Heap, this means there’s a store for holding event definitions, a separate store for holding reports, and so on.
Architecture
When you visit a page on the Heap web app, the request goes through our Backbone routing layer. From there, the routing layer chooses a React container component to render based on the URL. Container components retrieve the data they need from MobX stores and send it down via React props
to presentational components. When a user interacts with the UI, the callback for the event handler eventually reaches a MobX store which may interact with the transport layer (e.g. to persist an update to our database), and/or update its local domain model state.
To make this flow more concrete, we’ll go through an example of how we might write a small feature in the Heap web app from scratch. Along the way we’ll talk in more depth about the principles underlying our front-end architecture, and how they make our code easy to test and extend.
Building the Frontend for a Notification System
Suppose we’re writing the frontend for a notification system within Heap. If a user executes a complex, long-running query, we might want to let them run additional queries while waiting for their first query to come back. We can let users know when their queries complete by showing them a notification. For the purposes of this example, the user should be able to view their notifications and mark them as read.
Before we proceed, there are some MobX-specific annotations we use that are important to understanding the code below. Functions annotated with @computed
are derived from properties marked as @observable
. These functions are assumed to be pure, and so MobX can cache their results. If any properties marked with @observable
change, any @computed
functions that rely on their value will be re-computed. The fact that this happens automatically through MobX turns out to be super useful since it means we get caching for free, and it reduces the amount of actually modifiable state.
We also make extensive use of Lodash (denoted by an underscore in the code examples below). Lodash provides several utility functions which help make our code more declarative and succinct. We’ll use some of these functions when we write our MobX store.
Models and Stores
We’ll start with thinking about what our notification data model might look like. For simplicity, we’ll say that a notification has an id
, some textual content
, a creation time
, and some state indicating whether it was read
.
We’ll store a user’s notifications as a map from ID to notification.
We’ll initialize the NotificationsStore
in a special store called the root store. This root store is a singleton which holds references to instances of the various domain stores in our app.
Root Store > Singleton Stores
In the past, we wrote all of our stores as singletons. While singletons have their advantages, they are notorious for making testing difficult. It’s easier to construct stores with the data we want in our unit tests than trying to mock out the global state contained in singleton stores.
Another problem with using singleton stores was that the dependency graph between stores was often unclear. Stores were created in some non-obvious order and there was no way to declare which stores depended on other stores. Since domain models often reference other models, so do their corresponding stores.
Dependencies are made much more obvious by the order of initialization in a root store. The rule we follow is that any store is allowed to depend on any other store created before it. We can make these dependencies even more explicit by directly passing in the store instance when instantiating other stores. For example, if the NotificationsStore
wanted to make calls to functions defined in a QueriesStore
, we could do something like NotificationsStore.initialize(notifications, this.queriesStore)
as long as this.queriesStore
had already been initialized. This sort of dependency injection is used throughout our codebase and makes testing a breeze, as we’ll see later on.
Now that we have our data in MobX stores, we’re ready to start displaying notifications to the user.
Container and Presentational React Components
When designing React components, we’ve found the separation of container and presentational components to be invaluable. These components are described in detail in Dan Abramov’s article, but we’ll summarize the differences below.
Container components are responsible for retrieving data from MobX stores and passing the data down as props
to their child presentational component. Presentational components are then responsible for actually rendering HTML to the DOM. This separation of concerns makes our code much easier to understand. It also allows for reusability since presentational components can be used with different data sources.
Now that we’ve talked about the two different kinds of components, we’ll look at NotificationsViewContainer
. This container simply passes down data from the NotificationsStore
to the presentational NotificationsView
.
You’ll notice we marked this container as being an @observer
. This annotation comes from a MobX extension for React. A component marked with @observer
is automatically re-rendered whenever any of the @observable
properties (including @computed
functions) it uses in its render()
function are updated. So when the state of read and unread notifications is updated, NotificationsViewContainer
is automatically re-rendered with the new data. We can annotate container components with @observer
to ensure the data they present is always up to date without having to write any additional code.
You’ll also notice that instead of directly calling methods on an imported singleton store, we pass the store instance itself as a prop
to this top-level container component. This dependency injection makes it explicit what data is required by each of our components. This also simplifies unit testing of components. We can construct exactly the stores the components depend on without worrying about any hidden dependencies.
Now that we have our container component set up, we’ll look at the presentational NotificationsView
that actually renders the read and unread notifications.
Clicking on an unread notification calls the onMarkAsRead
function provided as a prop
to this component, which in turn calls the appropriate store function to mark the notification as read. Finally, NotificationsViewContainer
re-renders in response to the readNotifications()
and unreadNotifications()
being updated and the NotificationsView
immediately reflects the new state!
Testability
Now that we’ve written the code for our models, stores, and components, it’s time to add a couple unit tests. We’ll use Mocha, Chai, and Enzyme to test our container component:
Here, we construct a NotificationsStore
with the exactly the data we want and pass it to NotificationsViewContainer
as a prop
. We then render our component using Enzyme’s mount() function. Finally, we assert that a NotificationsView
is rendered with the props
we expect.
The alternative to passing NotificationsStore
as a prop
would be to directly import the RootStore
in NotificationsViewContainer
. Testing this component with mock data would then require mocking out the import itself, which while possible, is far from ideal. It’s much more obvious what’s going on when we construct the store instance and pass it to the component under test.
Testing the NotificationsStore is similarly straightforward. We’ll initialize a store with some mock data, and assert that the read and unread notifications are as we expect.
Extensibility
This architecture makes our stores and components easier to extend in a couple of ways:
We can pretty easily create new domain stores by following the same pattern as our
NotificationsStore
. We’d just have to pass in any dependencies our new store relies on, and initialize it in the root store. If we want a container component to use this store, it’s as easy as passing in that store instance as aprop
.The layer of abstraction between stores and presentational views means changes to our information schema (e.g. using different data structures to hold our domain models) can be made reliably without updating our presentational components. Similarly, we can modify presentational views without having to update any of our stores or container components.
Final Remarks
MobX and principles like dependency injection have helped us tremendously toward the goal of having a codebase we can be proud of. These principles allow us to work on new and existing features while ensuring testability and extensibility, and we hope they help you too. We’d love to hear what’s worked (and what hasn’t worked) for you, so feel free to reach out to us on Twitter. Finally, if this sort of thing interests you, we’d love to have you join us at Heap!