This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 10% discount for team members. Become a Subscriber

We look at an example of a reactive pipeline with surprising behavior, discuss why it occurs, and how it could be improved.

00:06 Today we'd like to show a specific problem with reactive programming that comes up in practice a lot. We'll discuss why it happens and what we can do about it.

Constructing an Example

00:31 Let's say we're building an imaginary settings app that has switches for airplane mode, Wi-Fi, and cellular data. When airplane mode is enabled, Wi-Fi and cellular have to be disabled. When airplane mode is disabled, we have to take the two original settings for the Wi-Fi and cellular switches. Finally, we want to keep track of when both Wi-Fi and cellular are enabled.

01:05 We start to build the logic using a minimal reactive library we wrote:

let airplaneMode = Observable<Bool>(false)
let cellular = Observable<Bool>(true)
let wifi = Observable<Bool>(true)

01:50 To determine the state for cellular, we combine an inverted airplane mode with the cellular setting:

let notAirplaneMode = airplaneMode.map { !$0 }
let cellularEnabled = notAirplaneMode.flatMap { na in
    cellular.map { $0 && na }
}

03:02 Ideally, we would've used a function like combineLatest in the above snippet, but our reactive library only offers map and flatMap, which also works.

03:14 We observe the cellularEnabled property and print its value. If we turn on airplane mode, we get a false for cellularEnabled:

cellularEnabled.observe { print($0) }

airplaneMode.send(true)

/*
true
false
*/

03:43 So far so good. We do some copying and pasting to add wifiEnabled and the value we're ultimately interested in, wifiAndCellular:

let wifiEnabled = notAirplaneMode.flatMap { na in
    wifi.map { $0 && na }
}
let wifiAndCellular = wifiEnabled.flatMap { we in
    cellularEnabled.map { $0 && we }
}

04:41 Now we can observe the latter property to see if both Wi-Fi and cellular are enabled. We print some dashes to make clear what's happening — we start out with true for wifiAndCellular, and then we enable airplane mode and we get false twice:

wifiAndCellular.observe { print($0) }
print("–––")
airplaneMode.send(true)

/*
true
–––
false
false
*/

Glitches in the Result

05:13 The observer of wifiAndCellular gets called twice, because the new value travels over two paths, via wifiEnabled and cellularEnabled. This is an interesting effect, but it's not yet the problem we want to show. If we disable airplane mode again, we see that the observer first gets called with true and then with false:

wifiAndCellular.observe { print($0) }
print("–––")
airplaneMode.send(true)
print("–––")
airplaneMode.send(false)

/*
true
–––
false
false
–––
false
true
*/

06:05 The observer getting called with two different values is surprising, but it happens for the same reason as before: the value traveling down two different paths and rejoining in the end. The following diagram shows us what's happening:

06:27 Here we see the dependencies of all properties. By sending a new value to airplaneMode, its children get triggered. After notAirplaneMode, the value travels down the left branch to our observer, wifiAndCellular, at which point the value for cellularEnabled, in the right branch, hasn't yet been updated.

07:44 After the observer is called and we print the first output, notAirplaneMode continues with its other children and the value travels down the right branch to our observer, which now prints the correct value:

Problems with Reactive Glitches

08:04 In practice, this weird behavior doesn't have to be problematic. If we take a value from a reactive pipeline and bind it to a label, we may end up setting a wrong value briefly before we set the correct value. This isn't the most efficient, but it still works. However, if we were to do something else with the value, like start a network request, write to a file, or append to an array, then it is a problem that we get multiple values — even wrong ones — in between.

08:45 It's up to the developer to choose the right combinators for their graph to mitigate unwanted effects. In this case, we should've used a function like zip — where we join the paths together — instead of flatMap, in order to wait for the values from both branches before continuing.

09:24 Before we go to the solution, let's look at another abstraction problem. Our three uses of flatMap are sort of a reactive version of &&. We could write a function that combines observable booleans this way:

func &&(lhs: Observable<Bool>, rhs: Observable<Bool>) -> Observable<Bool> {
    return lhs.flatMap { l in
        rhs.map { $0 && l }
    }
}

10:20 We'd like to write this function and use it, but it's unfortunate that we're calling flatMap in its implementation. We should really leave this choice to the developer that uses the framework. This is because, in some cases, they might not want to use flatMap, but rather zip, as in our current example.

Topological Sorting

10:45 A modified version of our reactive library offers a solution. We copy our sample code into another playground with the updated library. The output is very different, because this library processes the observed values differently. Now we only get out a single printed value each time we send a new value in:

true
–––
false
–––
true

11:37 To understand what the library does, we look at the diagram again. We reorganize the graph in different levels of height (or depth). The bottom of the graph is height zero, and with each level up, the nodes get a higher number:

12:41 What happens when we send a new value to airplaneMode is visualized in the animation below. Here's the gist of it: sending a new value to airplaneMode places all of its children in a queue. airplaneMode only has one child, and after triggering it, we move on to notAirplaneMode, which has two children. Both are put in the queue, sorted by height. In this case, both wifiEnabled and cellularEnabled have the same height, so it doesn't matter which is processed first. After processing wifiEnabled, we put its child (the map operation) in the queue. Now we have two elements in the queue with different heights, and because cellularEnabled has a higher number, we process it first. This puts its child in the queue, resulting in two items with the same height again, so we continue with either one of them. When we process the first one, its child, cellularAndWifi, is put in the queue, and after the second .map { ... } node is processed, we don't put its child in the queue because it's already there. Finally, the observer is queued and processed.

15:13 In short, the values flow through the graph in another way; they trickle down the different branches more synchronously, in an order you'd expect from a reactive framework. We now don't have to make a distinction between flatMap and zip, since the framework triggers all observers in the correct order.

Pros and Cons

16:12 We can now use the && operator for every step, including the last one where we would've had to use zip in the previous version of the framework:

let cellularEnabled = notAirplaneMode && cellular
let wifiEnabled = notAirplaneMode && wifi
let wifiAndCellular = wifiEnabled && cellularEnabled

16:51 Using the queue algorithm, we don't have to think about how to tie together our reactive properties, and we can use operators like &&. The tradeoff is that the internal processing of the queue has become more complex.

17:40 This algorithm is called a topological sort, because it sorts the graph's nodes in a topological way in order to process them in the correct order. Stick around for an upcoming episode in which we want to actually implement this algorithm, starting out with the first version of the library we showed.