State of the App: State Surfing

Imagine you could scroll through your app states like your browsing history.

What if the actions you take via the user interface were visible like a Git graph and you could jump around between revisions?

Or how about exporting any state into a file and re-applying it at a later time?

These are things I’ve always wanted to be able to do while developing an app and it is now possible, with Historian and Composable Architecture, an application architecture Brandon Williams and Stephen Celis are developing in their video series over at Pointfree.co.

Have a look at it in action:

I’ve largely ignored the app architecture “scene”, because it mostly seems to be about MVC variants or geared towards apps of massive scale that I have little opportunity to be involved with. I get why they exists and they probably solve tricky problems at scale but for what I do, MVC with sensible composition was fine.

Despite this, though, I have recently transitioned an app prototype over to Composable Architecture, and I feel it is of enormous value for app development.

It’s not just that it enables history browsing. There are other significant advantages to adopting Composable Architecture, which make it a very compelling choice. I will get to that in a moment but first let’s look at what Composable Architecture actually is.

State, Action, Reducer

Since Pointfree.co is a subscription service and not everyone reading this post will be a subscriber (you should subscribe though, what are you doing) I will briefly recap what it entails. While Pointfree.co’s material will help you understand the architecture much better than I could ever do it justice, everything I talk about and show is available as open source, and ready to be included in your own projects.

At the core of “Composable Architecture” rests the following mechanism:

Your app’s entire state is represented by a struct we’ll call State and which is kept in a Store. In order to change values in this struct you send Actions to the Store, defined in an enum Action. These actions are applied to the struct via a Reducer function, which is essentially a function of shape (inout State, Action) -> [Effect<Action>].

The [Effect<Action>] array deals with an aspect that often complicates app development: side effects. They encapsulate everything and anything you might want to do that does not directly apply to your State, for instance:

  • file system reads/writes
  • network requests
  • notifications

These actions will either be “fire and forget”, i.e. () -> Void, or send a new action to your Store, () -> Action

This may seem quite abstract but you can explore all the details in an example app called PFCompArch using an implementation of the Composable Architecture library. Note that Pointfree.co have pretty much confirmed that they will come out with an official release of their architecture, um, someday 😅. In the meantime, the above will get you started.

There are a few very interesting consequences that come with this architecture:

  • Your entire app state is represented by a single struct. If this struct is Codable (and that’s something that is quite feasible and desirable), nothing stops you from recording, archiving, or transmitting this state and inspecting or re-applying it to your application at a later time.
  • The stream of your actions gives you a detailed timeline of changes to your app. It is like a git graph for your app state where each action constitutes a diff that is applied to an initial state. Just like you can create a patch file from a commit, you can take an action and apply it to a Store.
  • Since the reducer is a pure function and the only way to modify your app’s state, everything becomes very easily testable. And since you can easily prepare the initial state, it becomes much easier to test intricate scenarios.
  • Finally, Composable Architecture, is, well, composable. That means the State, Actions, and Reducer can be broken down into smaller components, just like SwiftUI views, and then recomposed into the entire whole. This makes individual parts of your app their only independent world that you can work on and refine in isolation.
    In fact, Pointfree.co go so far as to place app sections into their own modules that can be compiled and used – in SwiftUI previews or Swift playgrounds – entirely on their own.

The Reality

Now I’m not going to lie: the reality of applying this fantastic architecture to an app is not entirely trivial. While Brandon and Stephen have done an amazing job of creating their own example app that aims to cover all the essential parts app development touches on, invariably you will come upon areas that are not covered by an example.

This is where it really helps to have built an intuition and understanding of how the concept works and what tools you have available. I will admit I feel like I'm still in the middle of this process. However, after several months of chipping away at this off and on, I’ve finally managed to transition an app prototype over to the Composable Architecture, and am very happy with the results.

While my goal initially was to simply have better testing capabilities, something else struck me and it is something I recall Chris Eidhof also exploring: creating a history viewer for your app state.

Past, Present, and Future

Amazingly, once I was done transitioning my app over to the Composable Architecture, it took me well inside a day to add this history viewing capability. Most of the time was actually spent working around some SwiftUI issues and conceptual gaps I still had with the Composable Architecture.

The result makes me really happy. In fact, inspecting the action stream gave immediate benefits by making obvious some necessary changes to the app’s actions, breaking them down further into more granular steps.

Take another look at the video at the beginning of this blog post and note how you can not only observe the actions and move through the states. You can also drag a state snapshot out to the desktop where it is saved as a JSON representation. And later, you can drop it back onto the history viewer to re-apply it. This is persisting your entire app state via drag and drop.

Imagine how this can facilitate things like

  • getting the app back into a state to debug an intricate UI or network issue
  • re-instate a nice app view for marketing screenshots
  • debug and improve your event stream by observing redundancies or weird action sequences – because haven’t we all seen UI glitches where we wondered what triggered that under the hood

Multipeer streaming, a.k.a “State Surfing”

Now, what actually prompted me to explore a history viewer was a thought that occurred to me when I saw Gui Rambos announcement of MultipeerKit recently: what if I streamed actions and app state over the network and displayed them?

Because while I find the initial take to add a history as a window to a macOS app quite nice and useful, that’s not something that as easily done on iOS. Even if you bring up a debug view inside you app, you’ll be hard pressed to operate it at the same time.

So how about having a history viewer that receives state messages over the network while your iOS app retains its regular interface and the viewer acts as the extra window?

Introducing Historian

Thanks to SwiftUI and MultipeerKit’s support for macOS and iOS, it was surprisingly simple to bring the viewer to both platforms and allow inspection and “state surfing” from whichever device you have available:

Note that the viewer is entirely generic. It accepts a String description for the action and a Data object holding the app’s state. Clicking a row sends the Data back to the app, which decodes it and resets its state to what it received.

As mentioned, the transmitting app doesn’t have to be a macOS app. You can just as well observe an iOS app with the iOS history viewer. Or observe it with the macOS version – either combination works. You can even observe an app from two viewers at the same time.

I have packaged up the history viewer – Historian – and published it on Github. Again, this viewer app is entirely stand-alone and listens on the local network for state broadcasts from an app that is appropriately instrumented to send them.

We’ll cover those instrumentation steps next.

Integrating Historian in your app

Adding support for Historian essentially means to set up your app to send its actions as Strings and its state as Data at a strategic top level location where all actions and state information is available. You do this by conforming to protocols.

The biggest hurdle to “State Surfing” is clearly adopting a whole new app architecture but there are enough great reasons to do this already – browsing your app history is just the cherry on top.

You can head over to Pointfree.co’s collection of videos to get started with adopting their Composable Architecture in case you haven’t done so already.

The good news is that once you have transitioned your app to the Composable Architecture – or if you’ve been using it all along – you are almost all the way there to surf your app states already.

Step by step

Let’s look at the steps required to add support for Historian to your Composable Architecture app.

Here’s an outline of the steps required. In order to establish common nomenclature, I assume that your top level view is a ContentView and that this view’s state is held in a struct ContentView.State – i.e. a struct State declared within ContentView.

This is a practise I adopted for my Composable Architecture apps and which allows me to refer to the state simply as State when within view context and to use the view as a namespace whenever I refer to it externally. (See this tweet on how to avoid name collisions with SwiftUI’s @State.)

There are only a few steps required to support Historian:

  1. Add the HistoryTransceiver SPM package as a dependency
  2. Make your app’s ContentView.State adopt the StateInitializable protocol
  3. Make your app’s ContentView adopt the StateSurfable protocol
  4. In your SceneDelegate (or AppDelegate for macOS) where you instantiate your ContentView and pass it to the hosting controller, instead instantiate HistoryTransceiverView<ContentView>, and call resume to start broadcasting.

Let’s go through the steps, what they entail, and why they’re required in detail.

Adding HistoryTransceiver

The HistoryTransceiver SPM package brings with it everything that’s required to start streaming your states and to integrate with Historian. Most importantly, it declares two protocols, StateInitializable and StateSurfable which specify all the required implementation details you need to take care of. The main task is to adopt these two protocols.

Adopting StateInitializable

The first one is simple: StateInitializable essentially specifies the requirements to transmit and instantiate State from Data:

public protocol StateInitializable: Codable {
    init()
    init?(from data: Data)
}

Your State needs to be Codable and provide init?(from: data: Data) in order to be streamed and instantiated from received Data.

The other required initialiser, init(), represents the initial or “empty” state. When users wind back the history all the way to the start, Historian will send a nil data Message as a signal, which in turn will inject a State instantiated from init() into your app. Therefore, you need to implement init() such that it provides your app’s initial state.

Adopting StateSurfable

The StateSurfable protocol deals with preparing your top level view to be wrapped in a HistoryTranceiverView. This view handles broadcasting state changes and receiving and applying state updates from Historian.

public protocol StateSurfable: View {
    associatedtype State: StateInitializable
    associatedtype Action
    var store: Store<State, Action> { get }
    static var reducer: Reducer<State, Action> { get }
    static func body(store: Store<State, Action>) -> Self
}

By implementing the required methods, you are preparing ContentView to be swapped out for HistoryTranceiverView

A typical implementation of this protocol should be as simple as the following:

extension ContentView: StateSurfable {
     typealias State = ContentView.State      // redundant, for illustration purposes
     typealias Action = ContentView.Action    // redundant, for illustration purposes

     static func body(store: Store<State, Action>) -> ContentView {
         ContentView(store: store)
     }
     static var reducer: Reducer<State, Action> {
         ContentView.reducer                  // redundant, for illustration purposes
     }
 }

It is essentially a mapping of your types and the reducer function to what HistoryTransceiverView expects and uses to wire everything up.

Update your SceneDelegate or AppDelegate

We’re almost done now. The final step is to swap out your ContentView. In a typical application this looks as follows. Find and replace

window.rootViewController = UIHostingController(rootView: ContentView(store: ...))

with

let contentView = HistoryTransceiverView<ContentView>()
contentView.resume()
window.rootViewController = UIHostingController(rootView: contentView) 

The call to resume() will set up the transceiver and initiate state broadcasting. If you don’t want to do this at launch, you can gate this call behind a menu item or some other UI element to turn it on.

And that’s it. With these changes in place, you can launch both Historian and your app and whenever actions fire, Historian will receive and display those updates. You can browse the list, step through the updates, and the macOS app even allows you to drag and drop state snapshots from and to Historian.

Wrapping up

As mentioned above, you can find an example Composable Architecture application on Github and this Pull Request shows you how to integrate Historian with it.

If you are following the Pointfree.co series and are familiar with their example app “Prime Time”, you can find the same integration done for their app in this pull request.

I hope you find this post useful and would love to see what apps you successfully instrument for “State Surfing”. Please drop me a line and let me know!

Follow me on twitter to catch announcements about Historian and State Surfing and please get in touch if you have questions, comments, or suggestions.