Picking RIBs: State Management at Scale
Mothership’s Carrier application enables local truckers to earn more money by matching them with loads for delivery. The application is powered by real-time data and an always up-to-date carrier location. Running on real-time data provides a best-in-class experience, but is pretty complicated to build. How can an application’s state and UI remain in sync with a constant flow of new data?
Mothership had previously used a hybrid Android/iOS application, built using React Native. It was clear that by using a shared codebase, we were making it more difficult on ourselves, especially as it relates to handling real-time data. So, we made the decision to rewrite the application for both Android and iOS using their respective native development tools.
Starting from zero can be a bit daunting. Development with the Android SDK provides no shortage of architectures and frameworks that can be used to improve code function and quality. Making the initial decision of which architecture to follow is extremely important–making the wrong choice could lead to another rewrite in the close future or it could lead to overly complex code making it hard to ramp up new contributors. Let me walk you through the steps we took to ultimately make this choice.
Much of our focus early on was on requirements gathering. Some of these included:
- If data is changed behind the scenes, the app should update immediately
- When a carrier gets through our onboarding approval process, their status should update immediately in-app
- If a customer requests a pallet moved, that request should show up without a refresh of the app
- As the carrier drives their truck, our directions should update to reflect their current location
Once we had all of the requirements clearly defined, we started investigating which architecture would best handle our needs, making the development process as easy as possible. The obvious choice at the time was MVVM; it’s a standard practice architecture utilized on both Android and iOS platforms.
In the past, MVVM has traditionally been the ideal application architecture to use because it provides a clear separation of responsibilities and is somewhat easy to test. MVVM consists of a Model, View Model, and View, each with their own respective roles.
Model — The data layer of an application. It cannot talk to the View or View Model directly.
View Model — The glue holding the Model and View together. It observes and manipulates data in the Model and uses listeners to update the View.
View — The UI layer of an application. It observes properties on a View Model and updates based on those values.
MVVM overcomes many of the drawbacks of MVP, such as the tight coupling between the view and presenter. MVVM does this by providing a more efficient and less dependent way of bridging the data and UI layers together. This architecture works great for most applications; it provides a clearly defined structure enabling developers to write clean and testable code.
However, this architecture can be difficult to work with as your application grows in size, especially when the application is driven by a real-time stream of data that can completely change application state or UI. With Android, the preferred way of changing views is using a navigation graph, which means that you must define every fragment to fragment transition that can be made. If you are building an application with hundreds of views, this quickly becomes a difficult-to-maintain mess.
RIBs to the rescue
Uber’s application can do so many things, and it performs on a massive scale–handling thousands of possible states and views. Their engineering teams developed an architecture framework to tackle the exact same problem we encountered: reacting to real-time data in an efficient and non-complex way.
RIBs stands for Router, Interactor, Builder.
Router — Responsible for attaching and detaching states and views. It is kept separate from the interactor to increase the testability of the interactor, reduce boilerplate keeping the interactor small, and keep parent and child interactors independent.
Interactor — Responsible for all business logic and determining which states and views to attach.
Builder — Responsible for constructing the router, interactor, view (if necessary), and all other state or view builders.
Using RIBs, your application code is structured like a tree. Every RIBs application has a root node, and from there, you traverse the tree with each node making its own decision of which child to attach next. There are two types of nodes, those with a view and those without. Those without a view are called an application state and those with a view are called a feature.
For example, our application uses a session repository that has an observable session object that tracks various user details and their authentication state. Making this observable means that we are notified when session state changes. In the diagram below, you’ll see our application’s
Root node and the 2 destination nodes we can go to. On application launch, the
Root node is attached and immediately observes the session state. On any state change, this
Root node will attach a child node, either
In the above example, all nodes are non-view nodes. If you take a look below at a simplified version of our
LoggedOut tree, the two child nodes are view nodes. In this simplified example, whenever the
LoggedOut node is attached, it will immediately attach the
Welcome node and display that view. The
Welcome node provides a listener for its parent node to observe actions made, like button presses. When the login button is pressed, the
LoggedOut node observer will react by attaching the
Login node, replacing the view.
When the views are replaced, there is no need to define any navigation actions because we no longer need to explicitly manage a navigation graph–our RIBs tree gives this for free as a function of application state changing! This drastically reduces complexity of the application because each node has a single responsibility; view nodes can focus on the UI provided by that node and non-view nodes can focus on the transitions between their direct children.
Bringing this back to state management, in the above examples, everything is driven by the real-time data available on the observable session state. Beyond just session state, our application has many other observable states we can use to drive navigation decisions. We can continue observing each of these states to traverse the
LoggedIn tree to ensure a user’s onboarding requirements are met. What this means is if a user's insurance information were to expire, for example, their application would immediately take them to the
ValidateInsurance node without any user interaction required or navigation actions defined, it just works!
This is just one simple example of how our real-time data controls what our application can do and display, and this control is handled entirely by business logic with views playing a very small role in the application as a whole.
It’s important to note that RIBs itself is not an architecture but rather a framework built around a pseudo-MVP architecture that solves the tight coupling problems of MVP. If you are building an application with large scale and real-time data, I highly recommend checking out RIBs to see if it’s right for you.
Some of our next projects include migrating our iOS application from plain MVP to RIBs, start using Jetpack Compose on Android to generate our views, and moving all remaining REST calls to streaming data. If what we’re working on sounds interesting, come work with us!
For more information on RIBs, check out the github page.