Reactive Code and Component Design
Functional Reactive Programming (FRP) is almost an industry standard on mobile today. If you're not familiar with it, here's a great read by Daniel Lew on fundamentals. In the first part, he explains the difference between passive and reactive components. I find this really interesting. I think we should talk more about what it means to be reactive before diving into any implementation. In this post, we will repeat his switch-lightbulb experiment, then try to reflect the learnings on component design.
Experiment is very simple. We have a switch that turns the light on or off. How would you build it?
If we design a passive lightbulb:
- Switch turns the lightbulb on/off when it's switched on/off.
- Switch knows about the lightbulb.
class Switch {
var state: Bool {
didSet {
lightbulb.state = state
}
}
let lightbulb: Lightbulb
...
}
If we design a reactive lightbulb:
- Lightbulb should be created with an object which reflects the state of the switch and observe it. (That object would be
Observable<Bool>
.) - Neither switch nor the lightbulb knows about each other. They are decoupled.
class Lightbulb {
private(set) var state: Bool = false
let disposeBag: DisposeBag = DisposeBag()
init(observable: Observable<Bool>) {
disposeBag += observable.subscribe { [weak self] value in
self?.state = value
}
}
}
class LightbulbFactory {
func make(with aSwitch: Switch) -> Lightbulb {
return Lightbulb(observable: aSwitch.observable)
}
}
There's no difference between output of these implementations from the user's point of view. However, by using reactive approach, we decoupled the switch and the lightbulb. This is where observables come in handy. It's easy to represent an object's input or output as an observable so we can pass it around instead of the component itself.
Designing Components
Would we always use an observable to represent a component's input or output? Let's think about it.
In switch-lightbulb example, the switch produces an infinite number of on-off events and the lightbulb is expected to react to them accordingly. That's why the following model make sense.
Input | Output | |
---|---|---|
Switch | - | Observable<Bool> |
Lightbulb | Observable<Bool> |
- |
In this case, Observable
solves our problem because both switch's output and lightbulb's input are event streams.
What if it was not an event stream?
Let's say we're designing a recipe list component. We could expect a recipe-fetcher as input, so that we can fetch the recipes with it and display them. What would this fetcher return?
- If the recipes are fetched synchronously from a database, returning
[Recipe]
would make sense. - If the recipes are fetched asynchronously from a server, then we would need a type like
Future<[Recipe]>
to indicate that recipe list is coming from a remote source.
Could we use Observable<[Recipe]>
instead of Future<[Recipe]>
?
Yes, we could. But should we? Remember, observables represent event streams. In this case, we're talking about a network request so it'll either return a value or an error. It's definitely not an event stream but can be represented using an event stream that receives only one value. Since observables are more than likely to receive more than one value, using Observable<[Recipe]>
as return type would set a wrong expectation on receiving end and therefore, is not a wise choice.
Using the correct abstractions is extremely important in programming because developers generate a mental model by looking at them. When we look at a block of code that we're not familiar with, we start developing a mental model by looking at names, types and connections. If we have the right model, everything becomes 10 times easier. Therefore, it's important that we thrive for better abstractions.
Looking at the examples above, we can generalize return types as below.
One | Many | |
---|---|---|
Sync | T |
Iterable<T> |
Async | Future<T> |
Observable<T> |
Takeaway here is that:
Observable
is not always the right abstraction.
One of the main goals of component design is to encapsulate certain functionality so that it can be reused across the codebase. It's really hard to do so without clearly defined responsibility, inputs and outputs. The table above is the toolkit that should be used to achieve this.
Bottom Line
Reactivity is extremely useful when designing decoupled components with clear input and output, especially when it involves user input. But this is not as trivial as it sounds. We first should be able to observe, to be able to react. An abstraction like Observable
is a great solution to this problem.
It's also very important to know that we have other tools under our belt. Observable
is a powerful abstraction but great power comes with great responsibility. We should use abstractions when they're needed, not when we can. This is very important to keep things simple and understandable. If you look at the return-type table above, you'll realize we actually can use an Observable
in all those cases, but that wouldn't be very wise.
If you read between the lines, you'll probably realize I'm not a huge fan of RxSwift
or ReactiveCocoa
as it's very easy (or even advertised in some cases) to abuse their Observable
/ Signal
implementation. I don't want to get into details now as it can be a blog post on its own. 😅
Hope this provides another perspective on FRP and popular frameworks around it.
Thanks for scrolling all the way! ❤️
Image Credit: Photo by Mikael Kristenson on Unsplash