Staring In My Face, But I Ain’t Looking: Simplifying RxSwift Subscriptions

When I addressed the second-to-last RxSwift reentrancy warning this week, I already cut my teeth on very simple state updates and refactored a them in a way that like much better now. I left the gnarly text editing component, the most involved piece of UI in a text editor, for last.

The reentrancy warning, it turned out, was closely tied to a code bath with buggy behavior: where the text editor would scroll to the wrong position when opening a file. It’s a nuisance, but not the end of the world, and I always wanted to address this “eventually.”

Yesterday was “eventually.”

The new approach is much simpler overall, and I now wonder how I could never have seen this. That’s the best kind of change: the one that feels obvious in hindsight.

The solution is all about lifetime management.

The engineering TL;DR take-away is this:

Think about managing subscription lifetimes with separate reference counting containers, and then clear some of these and re-subscribe as you see fit. In RxSwift, this manual reference counting tool is a RxSwift.Disposable that you stick into a DisposeBag. The Combine equivalent is Cancellables in a Set<Cancellable>.

class CommonExampleController {
    let viewModel: ViewModel
    let subComponent: SubComponent
    
    private let disposeBag = DisposeBag()

    // ...
    
    override func viewDidLoad() {
        super.viewDidLoad()

        viewModel.someInput
            .bind(to: subComponent)
            .disposed(by: disposeBag)
        // If the context is changing, you need to do some gymnasicts 
        // to combine it with view components. This is a contrived example:
        Observable
            .combineLatest(
                viewModel.changingContext,
                subComponent.someOutput
            )
            .subscribe(onNext: { $0.0.receiveOutput($0.1) })context)
            .disposed(by: disposeBag)
    }
}

We’re usually seeing samples where each service object, each view controller or view, has exactly 1 such container. The lifetime of the subscriptions inside this container is tied to the lifetime of the owning object.

I’ve been doing it like that out of reflex for years.

Now the text editor in my app is a long-lived view controller; one per window, showing many different notes during its lifetime. Each subscription for input and output ports need to be orchestrated carefully, so that file system changes, user interactions, typing in the editor, scrolling the editor, and changing which note is being displayed are fired so that they are associated with the correct note.

That’s a natural consequence of long-lived subscriptions that change context; that change which entity (here: note) they relate to. If the editor controls 1 note like you do in a document-based app, you know the reference from the get go; if the editor controls N notes, you need to annotate events with the referenced object. – In practice, a lot of events carried the ID of the note they were related to at the time of firing so I could ensure outdated events are discarded.

Outdated events happening at all also felt like a natural consequence of bridging AppKit UI components and their delegates into the “reactive monad”. Some delegate messages will fire while you’re in the process of changing contexts.

The scrolling bug manifested because some scroll events were associated with the wrong note, and then discarded. Basically the editor received a reactive state update to show note B, fired a scroll change for note the old note A which was being phased out, while the internal state was already set to B.

I was pointed to the solution by the reentrancy warnings: they would fire where the scroll position triggered a change while the “display note B” event was still being processed.

class UncommonExampleController {
    let viewModel: ViewModel
    let subComponent: SubComponent
    
    private let permanentDisposeBag = DisposeBag()
    private var temporaryDisposeBag: DisposeBag?

    // ...
    
    override func viewDidLoad() {
        super.viewDidLoad()

        viewModel.someInput
            .bind(to: subComponent)
            .disposed(by: permanentDisposeBag)

        // NB: I'm adapting the original example, but the context change
        //     actually arrived outside of the Rx monad in my app.
        viewModel.context
            .subscribe(onNext: self.changeContext(_:))
            .disposed(by: permanentDisposeBag)
    }

    func changeContext(_ context: Context) {
        self.temporaryDisposeBag = nil
        // ... perform clean up, like a 'will set' observer would ...
        
        let contextDisposeBag = DisposeBag()
        subComponent.someOutput
            .bind(to: context)
            .disposed(by: contextDisposeBag)
        // Stays alive until next changeContext call:
        self.temporaryDisposeBag = contextDisposeBag
    }
}

The solution came to me from changing subscription lifetimes in the past couple of days: that I would be better off if I could have separate subscription lifetimes per displayed note. Then I wouldn’t need to combine “which note is displayed” with “some editing event” to get an ID-annotated event anymore – which apparently failed here.

So the text editing view controller now has two DisposeBags: one for permanent subscriptions (like which theme to display so it can change its colors), and one for per-note subscriptions. The latter is being thrown away as a new note is displayed. Before a new note is being displayed, a save signal ensures that observable sequences of pending content changes, scroll positions, and selections are being fired once again for the current note before its subscriptions are disposed.

This works spectacularly well.

As a bonus, I can remove the reactive stream of “which note is displayed” completely: I can pass the note’s ID to the methods that wire all non-permanent subscriptions as a parameter.

The ID won’t change during the lifetime of these subscriptions anymore.

The subscriptions are instead being replaced as the ID changes.

Turning things around like this replaces N subscriptions that need to correctly update themselves and thus carry more state with 1 subscription replacement.

Replacing N with 1 is the definition of simplicity. I can almost feel how much lighter weight the code is as a consequence.

In the last post, I mentioned that it would’ve been easy to avoid all the reentrancy warnings if only they were presented to me sooner. Maybe I could’ve thought similar thoughts back then, too. But spending a couple of days replacing how the lifetime of reactive subscriptions are managed definitely warmed me up to come to the (then) natural-feeling conclusion that I need to have a second, replacable DisposeBag in my view controller.

That thought I never had before. It’s honestly novel to me. And it’s opening up new (to me) ways to model reactive code.