How We Built Our UI
Last year, we had that rare opportunity to reflect upon our UI architecture and evaluate whether it would continue to serve us going forward. In the end, we decided to take a fundamentally new approach. Under the new architecture, the UI would now be a client-side application filled with visualizations and complex interactions. Our frontend team critically reviewed our previous UI application to determine whether it would scale with the new platform architecture. In the end, we determined it was best to rewrite our UI from scratch. Our new patterns and our new development strategy had to allow our engineers to rapidly work in parallel on new features.
Architecting a UI has many long term implications and requires thoughtful debate. Our team scrutinized existing projects, including what worked, what didn’t work, and how we could fix these issues in the future. We researched emerging technologies and shared our findings with other engineers. As a team, we developed shared understanding around key pieces of the design to arrive at the new architecture we use today. This post will walk through the process and decisions we made along the way toward building our UI.
The first decision in designing our architecture involved moving toward functional reactive programming by using purely functional view components. View components - the components responsible for visualizing data to the user - would be stateless. A view component renders based upon the data it was given and never has any notion of internal state. If all view components in your application are purely functional, then the UI will always look the same given the same application state. The view components are simply a function of the data.
Creating purely functional view components allows us to completely isolate presentation concerns. Our view components now only have one responsibility: to present the data to the user. The code for our views becomes simpler, easier to test, and easier to reason about. After agreeing upon this first principle, we were able to move forward to the next decision in our design.
Where Does the Application State Live?
Actions are objects dispatched by view components (or elsewhere in your application). They describe an event that happened in the application. Actions must always contain a “type” property, which is a string describing the event. Actions may also include other properties containing data pertaining to the event.
Reducers are functions which take actions and the current state of the application and synchronously return the new state of the application as a result of applying that action. Reducers are the only piece of the architecture which may modify the store.
Below is a diagram which shows our architecture at this stage: purely functional view components with Redux as a state container.
Due to our first decision of using purely functional view components we know that views strictly read in data from the store and are a function of the data they are given. As a result, the views only update when the store changes. The store only changes when actions are fired. It is very easy to model simplistic UI workflows using this architecture:
The app loads and the store is defaulted to an initial state.
The view reads from this state and renders a page with a button.
The user clicks the button on the view which dispatches an action.
The reducer receives the action and modifies the store accordingly.
The view renders the new data from the store.
So far I have described how we chose to handle interactions between our users and the browser. However, we needed a way for our application to deal with side effects and asynchronous operations, such as fetching data from a backend service. We decided to use the saga pattern, a common pattern for handling business processes in event-sourced systems, to handle side effects.
Think of a saga as a separate thread in your application. It is a long-running process for handling business transactions and the errors which may arise during those transactions. A saga listens for events (Redux actions in our case) and then dispatches commands to other parts of the system. Actions may be dispatched by users creating side effects, by a backend service responding to a side effect, or by our application during a business process. Using sagas allows us to model these three actors in our system: the user, the application, and the server.
Now that I've illustrated how we handle user interactions and asynchronous operations like HTTP requests, let's take a look at a simple example of how this all comes together.
Imagine you are building an application which translates a message from Spanish to English. There is an input where you type your desired message and a “Translate” button. The translation is done via a backend web service which will respond with the English translation. Our application could look something like this:
1. When the application loads initially there is no current translation on the page and the application is not currently fetching a translation. Our store has an initial state which our view reads from. The store looks like this:
2. The user types “Hola” into the input and clicks the “Translate” button.
3. The click handler for the “Translate” button grabs the value from the input and dispatches an action with the type property set to “userClickedTranslateString” and a userInput property set to “Hola”.
4. The reducer receives this action and changes the application state to reflect that a request is pending by setting the isLoading property to true. Our reducer does not care about the value the user typed in and ignores the userInput property.
5. Since the store has changed, the view re-renders to reflect the new state of the application. Because the isLoading property is true, our application renders a loading spinner and a message informing the user that their message is being translated.
6. The saga also receives the “userClickedTranslateString” action. Unlike the reducer, the saga does care about the userInput property on the action. It reads this property and sends it in an HTTP Request to the backend service.
7. When the backend service responds with a translation (“Hi” in our case), the saga dispatches an action with the type property set to “serverTranslatedString” and a translationResponse property on the action containing the translation returned from the HTTP Request.
8. The reducer receives this action and updates the state accordingly. The request is no longer pending so it sets isLoading to false, and sets the store’s translation property to the value of the translationResponse property on the action.
9. Since the store has changed, the view will re-render to reflect the data from the store. The isLoading property is now set to false. The spinner is removed and the translation property from the store is rendered to the user.
This was an overly simplified example to help clarify the roles and responsibilities of each of the pieces of our frontend architecture. Each piece has a single responsibility and reflects the output of comprehensive and thoughtful debate and research to help us craft the most flexible and maintainable UI for the Endgame platform.
Angular vs React
We were confident we could build an impressive and scalable UI application with either library. Following our own spirited debate about which library to use, we eventually landed on using React. With React, you create your view components using an HTML-looking syntax called JSX and can pass the requisite data via a mechanism called props. When the value of a prop changes on a component, React will force that component to re-render. As a developer, you simply write a “render” function which returns the markup for your component. You never have to manually add or remove elements from the DOM. React does all of this for you under the hood via reconciliation.
There are many high-level architecture decisions required to build an impressive UI. Importantly, we agreed upon an architecture first and chose libraries to suit our decisions. Solidifying the architecture first provided us the flexibility to then make the subsequent decisions free from constraint and to focus on the best tool for the job. We highly suggest this method to anyone starting a greenfield project. Stay tuned for my next blog post where I will cover the code and patterns we developed while building Endgame’s UI with this architecture!