Author: Ivan Fabijanović, Senior iOS Engineer, Noom
Computer programs are composed of variables (state) and logic that change those variables (state transformers). When starting a new project we usually begin with a small state and go straight for logic. When a flag is needed here, something needs to survive app restarts so it’s saved to disk, two threads then need to change the same object and so on. As logic grows state can easily explode into a huge mess. Correctly managing state (and it’s growth) is crucial for developing maintainable and extensible apps in the long term. Failing to do so will lead to crashes, race conditions, data loss and generally hard to maintain apps.
ReactiveX is a tool we can use to greatly reduce state complexity, write cleaner (testable) logic and take back the control of our app.
What is ReactiveX?
As the official page says, “ReactiveX (Rx) is an API for asynchronous programming with observable streams. It introduces the concept of Observable which fundamentally changes how we think about state”. Take this very simple state in Swift:
Nothing fancy here, a simple variable that can assume two values – true or false. But notice this variable is defined as var which means its value can be changed. This introduces a whole new dimension to this state: time. Any time the value is read it is immediately potentially stale as another process could have changed it in the meantime.
Which color will the view be?
Most likely blue, but are we sure? It’s nondeterministic.
Using Rx we can change the state to be a stream.
This means our state is no longer just the current value – it is all values through time. Let’s modify the above example to use Rx and back the state with a simple PublishSubject implementation.
Now whenever the state is updated all subscribers are notified and they can do whatever they want to do. This brings us to the one fundamental concept people starting out with Rx have problems with — treating EVERYTHING as a stream. Variable is a stream of values, button tap is a stream of events, animation is a stream that simply completes when done. When everything is a stream then everything can be composed, merged, transformed, delayed, retried, canceled, scheduled and so on using the same rules.
Putting it into practice
Most apps have some kind of login flow for users. A user is given limited access to areas of the app (and by extension, any services consumed by the app) until they login with a user account, at which point they get full access. Once logged in the user can choose to logout manually or be logged out by some process (failed token refresh for example), returning them to limited access.
Let’s paraphrase that – most apps have some kind of isAuthenticated state which determines access to different parts of the app for users and logic that mutates this state (login/logout).
Managing this seemingly simple state is actually not that simple. Here are some of the flows that need to be handled:
- User tries to log in -> wait for confirmation from backend -> update state -> trigger navigation to authenticated part of app
- User tries to sign up -> wait for confirmation from backend -> update state -> trigger navigation to authenticated part of app
- User manually logs out -> update state -> trigger navigation to unauthenticated part of app
- User tries to perform an action requiring authenticated access -> check state -> can perform -> perform action
- User tries to perform an action requiring authenticated access -> check state -> cannot perform -> trigger navigation to unauthenticated part of app
- User tries to perform an action requiring authenticated access -> check state -> can perform -> wait for response from backend -> 401 -> trigger navigation to unauthenticated part of app
- Some process tries to perform an action requiring authenticated access -> check state -> can perform -> wait for response from backend -> 401 -> trigger navigation to unauthenticated part of app
All flows either read or mutate the state and then usually have a side effect of triggering navigation.
Case 5 is weird – we think we can take an action but then it turns out we can’t – which should not happen (and can lead to nasty bugs if someone forgets to check state).
Case 7 is problematic – processes doing work in the background should not care (or know) about UI. Imagine if there are several running when a 401 happens – we should cancel everything before going back to unauthenticated state and not trigger multiple instances of navigation, causing glitches in the UI.
So, how can Rx help us simplify this problem? Similar to the first example, let’s break it down by starting with the state. Usually there is some kind of User object which represents the authenticated entity.
Next, let’s expand the AuthenticationService with state mutators:
The first two functions mutate the state by pushing a new User value, while logout pushes a nil value. This is all the information needed to setup a robust application architecture. Logic code which requires authenticated access can be composed to this state and then the entire app stack can be constructed from a single stream.
The AppDelegate creates a long lived subscription to the AuthenticatedServices stream and navigates depending if value exists or not. Methods defined in AuthenticationService just mutate the underlying state, so calling them from anywhere in the app – be it user input or a failed HTTP request will trigger a change that can trigger navigation.
Cases 1, 2 and 3 now only need to call the appropriate service methods and handle errors. In case of success the above stream will take care of navigation.
Notice that services are passed to navigator when going to authenticated state – this resolves case 5 in a compile-time safe manner by passing a non-optional instance to authenticated part of the app. If user stops being authenticated for whatever reason, navigation triggered by the above stream should have a cascade effect of destroying any authenticated screens, which in turn destroy their DisposeBag objects, which cancels any still running operations – so case 7 is covered as well. Case 4 is now simplified to just performing an action – nothing needs to be checked.
Rx simplifies state management by making the time component explicit. State mutations can be clearly separated from side effects, enabling controlled changes to the code base.
I hope the examples presented here have piqued your interest in Rx if you never used it, or have given you something to think about when you go back to your own Rx-powered code base.
Interested in learning more? Check out our current job openings here and join our team today!