🎉 Black Friday Sale! Save 30% when you subscribe today.

Derived Behavior: The Point

Episode #150 • Jun 21, 2021 • Free Episode

We typically rewrite vanilla SwiftUI applications into Composable Architecture applications, but this week we do the opposite! We will explore “deriving behavior” by taking an existing TCA app and rewriting it using only the SwiftUI tools Apple gives us.

Collection
Derived Behavior
Derived Behavior: The Point
FreeThis episode is free for everyone.

Subscribe to Point-Free

Access all past and future episodes when you become a subscriber.

See plans and pricing

Already a subscriber? Log in

Introduction

So this is pretty cool. Using some pretty advanced techniques from SwiftUI we have been able to build tools for the Composable Architecture that allow us to embrace better data modeling for our applications. We can now use enums for our state and emulate the idea of destructuring a store by using a SwitchStore view with a bunch of CaseLet views inside. We can even provide some basic support for exhaustivity checking so that if the state gets into a case that is not handled by a CaseLet view we will breakpoint to let the developer know there’s additional state to handle. And on top of all that we’ve even made the view efficient by making sure it recomputes its body if and only if the case of the enum changes.

We can even push some of these ideas a bit further. With a little bit of extra work you can also support default-like statements for SwitchStore. This can be handy if you have a lot of cases in your enum and you want to handle only a few while allowing all the others to fall through to a default view. We have some exercises for this episode that will help you explore these ideas.

In the past 4 episodes we have really dug deep into the concept of “deriving behavior”, which means how do we take a big blob of “behavior” for our application and break it down into smaller pieces. This is important for building small, understandable units for your application that can be plugged together to form the full application, as well as for modularizing your application which comes with a ton of benefits.

We started this exploration by first showing what this concept looks like in vanilla SwiftUI by using ObservableObjects. Apple doesn’t give us direct tools to be used for this problem, but we are able to use some tools from Combine in order to break down large view models into smaller domains. We got it to work, but it wasn’t exactly pretty. We had to do extra work to get the parent domain to update when a child domain changed, and we had to do some trickery to synchronize changes between sibling child domains without introducing memory leaks or infinite loops.

Then we turned our attention to the Composable Architecture. We showed that out of the box the library gives us a tool for breaking down large pieces of application logic into smaller pieces, which is the .pullback operator on reducers. And the library gives us a tool for breaking down large runtimes, which is the thing that actually powers our views, into smaller pieces by using the .scope operator on stores. These two tools allowed us to build features in isolation without any understanding of how they will be plugged into the larger application, and then it was trivial to integrate child feature into parent features.

Once we got a feeling for how pulling back and scoping work in the Composable Architecture we started flexing those muscles more. We started exploring tools that allow us to embed our domains into a variety of data structures, such as collections, optionals and enums. This includes using reducer operators such as the .forEach operator that allows you to run a reducer on every element of a collection, the .optional operator that enhances a reducer to work on optional state, and even a new version of .pullback that pulls back along state case paths for when your state is an enum. Corresponding to each of those reducer operators were new SwiftUI views for transforming the store, such as the ForEachStore, IfLetStore and even SwitchStore.

That was all pretty amazing, but now it’s time to ask: what’s the point? This is our opportunity to try to bring things down to earth and maybe even dig in a little deeper. This time we want to end this series of episodes like we started: we want to show what one must do in vanilla SwiftUI to handle things like collections of domains and optional domains so that we can better understand how it compares to the Composable Architecture and see why it is important to have tools that are tailored for these use cases.

So, let’s try building our demo app with the collection of counters and fact banner in vanilla SwiftUI…next time!

Vanilla counter

Here we are back in our demo application that shows how to build a list of counters that each operate independently, and if you ask for a fact you get this little banner at the bottom, and even within the banner you can ask for more facts.

Let’s do the opposite of what we did at the beginning of the series: rather than take a vanilla SwiftUI app and translate it to the Composable Architecture we will take this more complicated Composable Architecture application and translate it to vanilla SwiftUI. We’ll do this conversion piece-by-piece so that we can see how the patterns of collections of behavior and optional behavior translate over to observable objects and view models. We’ll start with the leaf nodes and work our way back to the root view.

We can create a new file for our vanilla SwiftUI version of the app.

And we’ll copy over just the counter feature that is currently built in the Composable Architecture in order to convert it. Remember that the counter is a completely isolated feature and could even be extracted out into its own module so that it is provably isolated from the rest of the application. We would hope we can do the same in vanilla SwiftUI.

The Composable Architecture counter feature begins with a domain modeling exercise to figure out the features state, actions and environment and then implements a reducer to glue all of that together to form the feature’s logic. In vanilla SwiftUI all of that comes in a single package, which is the observable object. So let’s start there:

class CounterViewModel: ObservableObject {
  …
}

Each of the properties in the CounterState struct should become a @Published field in the view model:

class CounterViewModel: ObservableObject {
  @Published var alert: Alert?
  @Published var count = 0

  struct Alert: Equatable, Identifiable {
    var message: String
    var title: String

    var id: String {
      self.title + self.message
    }
  }
}

Then each case in the CounterAction becomes a method endpoint that can be invoked from the view:

class CounterViewModel: ObservableObject {
  …

  func decrementButtonTapped() {
  }

  func dismissAlert() {
  }

  func incrementButtonTapped() {
  }

  func factButtonTapped() {
  }

  func factResponse(result: Result<String, FactClient.Error>) {
  }
}

Inside each of these endpoints we will execute the business logic that happens in the reducer. The first three are straightforward mutations that basically translate directly, except we use self instead of state:

func decrementButtonTapped() {
  // state.count -= 1
  self.count -= 1
}

func dismissAlert() {
  // state.alert = nil
  self.alert = nil
}

func incrementButtonTapped() {
  // state.count += 1
  self.count += 1
}

The factButtonTapped method involves a side effect, which means we need to introduce dependencies to our view model so that this code has a chance at being testable. We can copy over the dependencies from the CounterEnvironment:

class CounterViewModel: ObservableObject {
  …

  let fact: FactClient
  let mainQueue: AnySchedulerOf<DispatchQueue>

  …
}

Cannot find type ‘AnySchedulerOf’ in scope

We now need to import CombineSchedulers to get access to the AnySchedulerOf type eraser:

import CombineSchedulers

Class ‘CounterViewModel’ has no initializers

And since we are dealing with a class now we are forced to provide an initializer:

init(
  fact: FactClient,
  mainQueue: AnySchedulerOf<DispatchQueue>
) {
  self.fact = fact
  self.mainQueue = mainQueue
}

In the Composable Architecture, reducers communicate with the outside world by returning effects that are run by the Store and their output is fed back into the system via another action.

return environment.fact.fetch(state.count)
  .receive(on: environment.mainQueue.animation())
  .catchToEffect()
  .map(CounterAction.factResponse)

View models aren’t forced into this rigid framework, so we can simply perform the side effect in-line by sinking on the publisher:

self.fact.fetch(self.count)
  .receive(on: self.mainQueue.animation())
  .sink(
    receiveCompletion: <#((Subscribers.Completion<FactClient.Error>) -> Void)#>,
    receiveValue: <#(String) -> Void#>
  )

We can handle failure in the completion block by showing an alert:

receiveCompletion: { [weak self] completion in
  if case .failure = completion {
    self?.alert = Alert(message: "Couldn't load fact.", title: "Error")
  }
},

Notice that we have to deal with memory management now since we are dealing with reference types. Here we have opted to capture self weakly, but others may prefer to use an unowned self, or even allow capturing self strongly since the fetch publisher shouldn’t be long living.

If a fact received:

receiveValue: { fact in
  <#???#>
}

What should we do here? In the Composable Architecture version of this application, the parent listens for this event so that it can show the fact banner at the bottom of the screen. So it looks like we will need a way to communicate from the counter domain up to the parent, but we will put that off for now and try figuring that out in a bit.

We have a warning, however:

Result of call to ‘sink(receiveCompletion:receiveValue:)’ is unused

We have to now explicitly hold onto this cancellable. This wasn’t necessary in the Composable Architecture version of the code because the Store is responsible for running all effects and it manages the set of cancellables for the entire application.

But now we have to do that work ourselves, so let’s introduce a set of cancellables:

private var cancellables: Set<AnyCancellable> = []

And store the fetch cancellable in that set:

self.fact.fetch(self.count)
  …
  .store(in: &self.cancellables)

The view model is now compiling, and it doesn’t look like we even needed the factResponse method so let’s get rid of it.

Now that we have a compiling view model we can delete the reducer.

And start working on the view. We’ll rename the view to VanillaCounterView:

struct VanillaCounterView: View {
  …
}

Then we’ll swap out the store for a viewModel, and it will be an @ObservedObject:

struct VanillaCounterView: View {
 // let store: Store<CounterState, CounterAction>
  @ObservedObject var viewModel: CounterViewModel

  …
}

We no longer need the WithViewStore concept to observe state changes because that’s happening automatically by virtue of the fact that we are using an @ObservedObject with our view model, so we can get rid of that wrapping view.

For the parts of the view that were previously sending actions to the viewStore we can now just invoke methods, and accessing the state in the viewModel is the same as the viewStore:

VStack {
  HStack {
    Button("-") { self.viewModel.decrementButtonTapped() }
    Text("\(self.viewModel.count)")
    Button("+") { self.viewModel.incrementButtonTapped() }
  }

  Button("Fact") { self.viewModel.factButtonTapped() }
}

For the .alert we can swap out the viewStore.binding helper for deriving a binding straight from the view model:

.alert(item: self.$viewModel.alert) { alert in
  Alert(
    title: Text(alert.title),
    message: Text(alert.message)
  )
}

And just like that our new view model is compiling. Looks like we didn’t even need the dismissAlert endpoint, so we can delete that method as well.

To make sure that this feature works correct we can create a new Xcode preview by constructing a VanillaCounterView that is passed a view model, and to construct that view model we need to provide its dependencies:

struct Vanilla_Previews: PreviewProvider {
  static var previews: some View {
    VanillaCounterView(
      viewModel: .init(
        fact: .live,
        mainQueue: .main
      )
    )
  }
}

Counting up and down works just fine in the preview, but tapping the “Fact” button doesn’t do anything because the parent is supposed to handle that functionality.

Vanilla counter row

We’ve already converted one of our Composable Architecture features to a vanilla SwiftUI view model. The biggest difference is that view models merge a bunch of concepts into one, whereas in the Composable Architecture, concepts are split into the pure logical reducer and the runtime store. View models smash logic and runtime together into a single unit. Vanilla SwiftUI is also a little bit shorter, which is nice.

Now that the counter domain has been converted to vanilla SwiftUI, let’s see what we can do about the counter row, which is just a small wrapper around the counter in order to show the feature inside the row of a list alongside a remove button.

We can follow the same steps as before. Let’s copy and paste the counter row domain over to this file and start converting it piecemeal. We will create a CounterRowViewModel observable object to represent the state and behavior of the feature:

class CounterRowViewModel: ObservableObject, Identifiable {
  …
}

Previously the Composable Architecture version of this domain held onto the counter domain. This means CounterRowState held onto a copy of CounterState, CounterRowAction held onto CounterActions, and the logic of the counter row was run off of the counterReducer. If in the future the counter row needs to run its own logic, it would have defined a new reducer for itself, and then combined it with the counterReducer pulled back to counter row domain.

In order to emulate this idea in vanilla SwiftUI we will hold onto a CounterViewModel from inside the CounterRowViewModel:

class CounterRowViewModel: ObservableObject {
  let counter: CounterViewModel
}

Now we are instantly faced with some things that we have to think about here. First off, we are nesting a view model inside a view model, which means its a reference type inside a reference type, and that can be tricky. Semantics of reference types can already be tricky since every mutation leaks to the outside world, but nesting them becomes even more subtle.

Second, we currently bind the counter variable with a let, but we are used to using @Published var fields in view models:

class CounterRowViewModel: ObservableObject, Identifiable {
  @Published var counter: CounterViewModel
}

However, this doesn’t work as you might expect. Any mutation that happens inside the CounterViewModel will not be observed by the @Published property wrapper. Only if you wholesale replace the entirety of counter with a fresh value will it be triggered.

So, we’re not sure if it’s more appropriate to use let or @Published var, but we’ll just keep it like this for now.

The CounterRowState had another field on it for uniquely identifying it amongst many a collection, and this value should not change so it is appropriate to just hold it as a let:

let id: UUID

Since the view model is a class we need to provide an initializer, whereas structs get an internal one automatically synthesized, so we’ll define one from scratch:

init(counter: CounterViewModel, id: UUID) {
  self.counter = counter
  self.id = id
}

Next we need to convert the CounterRowActions into view model methods. All of the CounterActions live on the CounterViewModel now, so anytime we want to invoke one of those we can just reach through the counter field to do so.

The one endpoint we do need to move over to the view model is removeButtonTapped:

func removeButtonTapped() {

}

But again, this logic does not live directly in this domain but rather is handled by the parent. We will need some way to have this child domain to communicate with the parent, which we will handle soon.

Next, in the view we just need to replace any references to viewStore with viewModel:

struct VanillaCounterRowView: View {
  @ObservedObject var viewModel: CounterRowViewModel

  var body: some View {
    HStack {
      VanillaCounterView(
        viewModel: self.viewModel.counter
      )
      .buttonStyle(PlainButtonStyle())
      Spacer()
      Button("Remove") {
        withAnimation {
          self.viewModel.removeButtonTapped()
        }
      }
    }
    .buttonStyle(PlainButtonStyle())
  }
}

While we reflexively added @ObservedObject to the view’s viewModel, there’s no state actually being observed, and in the original Composable Architecture version we captured this with the stateless transformation of the store. We can do something similar here by binding as a let instead:

let viewModel: CounterRowViewModel

Vanilla fact prompt

We’ve now converted another layer of domain over from the Composable Architecture to vanilla SwiftUI, and there’s just one more layer to get to the full application domain.

Before moving onto the root view of the application, which is the list of counters, let’s convert another leaf feature: the fact prompt. We can copy over the Composable Architecture code to this file so that we can start converting it.

First we’ll promote the simple FactPromptState struct to a view model class:

class FactPromptViewModel: ObservableObject {
  …
}

We will promote the mutable fields in FactPromptState to @Published var fields, but notably the count field was immutable and so it will stay a let binding:

let count: Int
@Published var fact: String
@Published var isLoading = false

We need a public initializer since we’re now a class:

public init(
  count: Int,
  fact: String
) {
  self.count = count
  self.fact = fact
}

And we can convert the actions to method endpoints on the view model:

func dismissButtonTapped() {
}

func getAnotherFactButtonTapped() {
}

func factResponse(Result<String, FactClient.Error>) {
}

The dismissButtonTapped method is another one of those user actions that actually needs to be handled by the parent, which we still haven’t taken the time to figure out yet, so we will wait on implementing that method.

The getAnotherFactButtonTapped method needs to execute some side effects, so just as we did with the CounterViewModel, we need to copy over the dependencies from FactPromptEnvironment:

let fact: FactClient
let mainQueue: AnySchedulerOf<DispatchQueue>

And we’ll need to update the initializer to take both the state for the feature and its dependencies:

init(
  count: Int,
  fact: String,
  fact: FactClient,
  mainQueue: AnySchedulerOf<DispatchQueue>
) {
  self.count = count
  self.fact = fact
  self.fact = fact
  self.mainQueue = mainQueue
}

Invalid redeclaration of ‘fact’

Looks like we’ve got a conflict of names here, so we need to rename something. Let’s rename the dependency:

let factClient: FactClient
…
factClient: FactClient,
…
self.factClient = factClient

We can now implement the getAnotherFactButtonTapped endpoint, where we first mutate ourselves to go into the loading state, and then fire off the fact client request:

func getAnotherFactButtonTapped() {
  self.isLoading = true
  self.factClient.fetch(self.count)
    .receive(on: self.mainQueue.animation())
    .sink(
      receiveCompletion: <#(Subscribers.Completion<FactClient.Error>) -> Void#>,
      receiveValue: <#(String) -> Void#>
    )
}

In the sink we can flip isLoading off in the completion block, and when we receive a fact we can reassign it on ourselves.

.sink(
  receiveCompletion: { [weak self] _ in
    self?.isLoading = false
  },
  receiveValue: { [weak self] fact in
    self?.fact = fact
  }
)

Notice that we again had to deal with memory concerns since we are in a reference type.

We also have an unused warning for the cancellable the sink returns, and so we need to again introduce a set of cancellables to hold onto the subscription:

private var cancellables: Set<AnyCancellable> = []

And store it:

.store(in: &self.cancellables)

That’s all there is to the view model, and notice that we never actually needed the factResponse endpoint, so we can remove it.

Next we have the view, and we just need to update all instances of the viewStore to instead refer to the viewModel:

struct VanillaFactPrompt: View {
  @ObservedObject var viewModel: FactPromptViewModel

  var body: some View {
    VStack(alignment: .leading, spacing: 16) {
      VStack(alignment: .leading, spacing: 12) {
        HStack {
          Image(systemName: "info.circle.fill")
          Text("Fact")
        }
        .font(.title3.bold())
        if self.viewModel.isLoading {
          ProgressView()
        } else {
          Text(self.viewModel.fact)
        }
      }

      HStack(spacing: 12) {
        Button(
          action: {
            withAnimation {
              self.viewModel.getAnotherFactButtonTapped()
            }
          }
        ) {
          Text("Get another fact")
        }

        Button(
          action: {
            withAnimation {
              self.viewModel.dismissButtonTapped()
            }
          }
        ) {
          Text("Dismiss")
        }
      }
    }
    .padding()
    .background(Color.white)
    .cornerRadius(8)
    .shadow(color: .black.opacity(0.1), radius: 20)
    .padding()
    .frame(maxWidth: .infinity, alignment: .leading)
  }
}

Vanilla app

So we’ve now converted 3 separate features from the Composable Architecture into vanilla SwiftUI.

There’s only one left, and it’s the hardest one yet. It’s the app domain that is responsible for bringing together all of the functionality of the other 3 domains.

Let’s bring over all of the corresponding domain we built in the Composable Architecture so that we can start converting it.

We’ll upgrade AppState to be an observable object:

class AppViewModel: ObservableObject {
  …
}

We’ll upgrade the mutable properties to be @Published vars. The counters collection will no longer be an identified array of CounterRowState, but rather will just be a simple array holding a bunch of CounterRowViewModels:

@Published var counters: [CounterRowViewModel] = []

Again we are encountered nested reference types, where now the AppViewModel class holds onto a bunch of CounterRowViewModel classes, which in turn holds onto a CounterViewModel class. The more we nest these reference types the more difficult it will be to reason about how the whole system works.

Further, using @Published on this array again does not work exactly as we would expect. Since CounterRowViewModel is a class we will not be notified of any changes within a particular element of the array, but we will be notified anytime an object is added to or removed from the array. So that’s a subtle behavior we have to keep in mind when trying to understand how changes to the model can trigger view re-renders.

We will also promote the optional fact prompt state to be an optional fact prompt view model, and so again we are nesting reference types:

@Published var factPrompt: FactPromptState?

Next we’ll implement each AppAction as a view model method. The addButtonTapped method handles adding a new CounterRowViewModel to the array of counters:

func addButtonTapped() {
  self.counters.append(
    .init(
      counter: .init(
        fact: <#FactClient#>,
        mainQueue: <#AnySchedulerOf<DispatchQueue>#>
      ),
      id: <#UUID#>
    )
  )
}

However, in order to create a CounterRowViewModel we need to pass along some dependencies, and so let’s introduce those to the class:

let fact: FactClient
let mainQueue: AnySchedulerOf<DispatchQueue>
let uuid: () -> UUID

init(
  fact: FactClient,
  mainQueue: AnySchedulerOf<DispatchQueue>,
  uuid: @escaping () -> UUID
) {
  self.fact = fact
  self.mainQueue = mainQueue
  self.uuid = uuid
}

And now we can finish implementing this endpoint:

func addButtonTapped() {
  self.counters.append(
    .init(
      counter: .init(fact: self.fact, mainQueue: self.mainQueue),
      id: self.uuid()
    )
  )
}

The other two actions in AppAction come from the other counter row and fact prompt domains. For the most part those domains do their thing in isolation, but there are a few particular instances where we want to understand what is happening inside them so that we can react.

Like when the counter row’s remove button is tapped:

case let .counterRow(id: id, action: .removeButtonTapped):

Or when the counter domain receives a fact response:

case let .counterRow(id: id, action: .counter(.factResponse(.success(fact)))):

And when the fact prompt’s dismiss button is tapped:

case .factPrompt(.dismissButtonTapped):

These are all of the child-to-parent communication channels we have alluded to a number of times in this episode, but still have not given a solution.

Before we get to that topic let’s convert the AppView to use this new view model so that we can have all the major pieces in place before we dive into more complicated things:

struct VanillaAppView: View {
  @ObservedObject var viewModel: AppViewModel

  var body: some View {
    ZStack(alignment: .bottom) {
      List {
        ForEach(self.viewModel.counters) { counterRow in
          VanillaCounterRowView(viewModel: counterRow)
        }
      }
      .navigationTitle("Counters")
      .navigationBarItems(
        trailing: Button("Add") {
          withAnimation {
            self.viewModel.addButtonTapped()
          }
        }
      )

      if let factPrompt = self.viewModel.factPrompt {
        VanillaFactPrompt(viewModel: factPrompt)
          .transition(.opacity)
          .zIndex(1)
      }
    }
  }
}

A few interesting things here is that we are able to lean on things like ForEach and if let rather than appealing to tools like ForEachStore and IfLetStore. It of course comes with some extra cost since it was only possible to use these tools due to the fact that we nested our view models, and we haven’t yet really seen the consequences of dealing with nested reference types.

But potential complications aside, we do now have a lot of the pieces of the application in place where we could actually get something showing in an Xcode preview:

struct VanillaContentView_Previews: PreviewProvider {
  static var previews: some View {
    NavigationView {
      VanillaAppView(
        viewModel: AppViewModel(
          fact: .live,
          mainQueue: .main,
          uuid: UUID.init
        )
      )
    }

    …
  }
}

We can add counters to our heart’s content, and even count up and down inside each row, but sadly the fact functionality does not work, nor can we remove a row after we add it. This functionality requires us to come up with a way to have the child domains communicate to the parent so that certain behavior can be implemented.

Vanilla communication

We’ve gotten all of the domains in place now. We were even able to get a preview running where some of the functionality works, but any functionality that depends on communication between domains is so far completely inert.

Let’s start with one of the simpler forms of child-to-parent communication: letting the counter row communicate to the app domain that the remove button was tapped so that the app behavior can remove that element from the array of counters.

The easiest way to do is to add a callback closure to communicate to the app view that the button has been tapped in the row view. We can do this by introducing a new property to the VanillaCounterRowView:

struct VanillaCounterRowView: View {
  let viewModel: CounterRowViewModel
  let onRemoveTapped: () -> Void

  …
}

And the remove button’s action closure can invoke this closure instead of calling the method on the view model:

Button("Remove") {
  withAnimation {
    self.onRemoveTapped()
    // self.viewModel.removeButtonTapped()
  }
}

To take advantage of this new onRemoveTapped callback we can provide our own closure when constructing the VanillaCounterRowView:

ForEach(
  self.viewModel.counters,
  content: {
    VanillaCounterRowView(
      viewModel: $0,
      onRemoveTapped: {

      }
    )
  }
)

And it’s in this closure that we can do the removing work.

onRemoveTapped: {
  self.viewModel.removeButtonTapped(id: counterRowViewModel.id)
}

And let’s implement this new method on AppViewModel for handling this logic:

func removeButtonTapped(id: UUID) {
  self.counters.removeAll(where: { $0.id == id })
}

Now when we run the preview we can see that the remove behavior works.

So, this is one way of communicating from child to parent. We can communicate via the view layer where the parent passes a callback closure, and the child uses it to let the parent know what is going on in the child. It’s very simple, but it also has some problems of its own. We’ll see some of these problems in a bit when we write some tests, but the most glaring problem is that this form of child-to-parent communication doesn’t work in all cases. There are times you need something a bit different.

To see this, let’s implement the behavior where the counter feature receives a response from the fact API request and we show a fact prompt banner over the main app view. We might think we can simply add an onFact callback closure to the counter view like we did for the row:

struct VanillaCounterView: View {
  @ObservedObject var viewModel: CounterViewModel
  let onFact: (Int, String) -> Void

  …
}

However, we don’t have access to the moment the fact response is received in the view layer. That’s all done in the view model. So this is an example where it’s not reasonable to communicate from child to parent with simple callback closures in the view. We’ve got to explore how we can get view models to directly communicate with each other.

One thing we could try to do is repeat the pattern we did for views, but in the view model. So, we could introduce an onFact callback closure to CounterViewModel:

class CounterViewModel: ObservableObject {
  …
  let onFact: (Int, String) -> Void
  …
}

Which means introducing it to the initializer:

init(
  onFact: @escaping (Int, String) -> Void,
  …
) {
  self.onFact = onFact
  …
}

And now in that empty receiveValue closure we can start invoking the onFact callback, but we just have to be careful of retain cycles and do a little bit of a dance to unwrap the optional self:

receiveValue: { [weak self] fact in
  guard let self = self else { return
  self.onFact(self.count, fact)
}

The only compiler error we have is in AppViewModel’s addButtonTapped method because when we create the counter view model we now have to provide this new callback closure:

func addButtonTapped() {
  self.counters.append(
    .init(
      counter: .init(fact: self.fact, mainQueue: self.mainQueue),
      id: self.uuid()
    )
  )
}

Missing argument for parameter ‘onFact’ in call

To fix the error we can open up the closure:

func addButtonTapped() {
  self.counters.append(
    .init(
      counter: .init(
        onFact: { number, fact in

        },
        fact: self.fact,
        mainQueue: self.mainQueue
      ),
      id: self.uuid()
    )
  )
}

And then in this closure is where we want to do the work to populate the fact prompt:

func addButtonTapped() {
  self.counters.append(
    .init(
      counter: .init(
        onFact: { [weak self] number, fact in
          guard let self = self else { return }
          self.factPrompt = .init(
            count: number,
            fact: fact,
            factClient: self.fact,
            mainQueue: self.mainQueue
          )
        },
        fact: self.fact,
        mainQueue: self.mainQueue
      ),
      id: self.uuid()
    )
  )
}

Wow, ok, this is intense. We have a strange nested creation of view models, where the act of creating a CounterViewModel causes us to open up a closure and create another view model inside. It’s particularly weird to see the FactClient and main queue scheduler being passed into two spots near each other, but each represents a very different context. On top of that we have to worry about retain cycles again, and this time it is definitely necessary to weakify self because we are setting up a long-living connection between the CounterViewModel and the AppViewModel.

But, at least the feature is now working. If we run the preview we can finally tap on the “Fact” button and get a fact populating the banner at the bottom of the screen. We can even ask for another fact, and the loading indicator works as expected and the new fact appears after a moment. The dismiss button does not yet work, and that brings us to our last example of child-to-parent communicate.

We can implement this communicate much like we did the remove functionality, by using a simple callback closure in the view layer. So, we’ll add that field to the VanillaFactPromptView:

struct VanillaFactPrompt: View {
  @ObservedObject var viewModel: FactPromptViewModel
  let onDismissTapped: () -> Void

  …
}

And invoke it when the “Dismiss” button is tapped:

Button(
  action: {
    withAnimation {
      self.onDismissTapped()
      // self.viewModel.dismissButtonTapped()
    }
  }
) {
  Text("Dismiss")
}

Then when we construct the VanillaFactPrompt view we can pass along a closure to handle tapping the dismiss button, and we’ll forward that to a method on the view model:

VanillaFactPrompt(
  viewModel: factPrompt,
  onDismissTapped: {
    self.viewModel.dismissFactPrompt()
  }
)

And that method doesn’t currently exist on AppViewModel, but it’s easy enough to implement:

func dismissFactPrompt() {
  self.factPrompt = nil
}

Vanilla testing

The app now works exactly as the Composable Architecture version, but we’ve accomplished everything using just the tools that SwiftUI and Combine give out of the box. Some things were a lot easier, such as our ability to deriving bindings right off the view model and use things like ForEach and if let instead of ForEachStore and IfLetStore. Other things were more complicated, like the fact that we now have a deeply nested hierarchy of reference types to represent the multiple domains in one cohesive package, we have to explicitly manage the lifecycle of our effects, and communication between parent and child requires more work.

Let’s take a moment to compare our vanilla SwiftUI code base with the Composable Architecture one in two key areas: testing and ease of adding a new feature.

We wrote a pretty succinct test for the Composable Architecture feature that played through a full user script of the user adding a counter, interacting with it, removing it, and more. Let’s look at that test and see what it takes to write the equivalent for our new vanilla SwiftUI feature.

Our test suite begins with a few simple steps: We simulate the user tapping the “Add” button, and assert that a new element was appended to the counters array in state, and then we simulate tapping the increment button inside the first row of the list and assert that that counter’s count field went to 1:

store.send(.addButtonTapped) {
  $0.counters.append(
    .init(counter: .init(), id: id)
  )
}
store.send(
  .counterRow(id: id, action: .counter(.incrementButtonTapped))
) {
  $0.counters[id: id]?.counter.count = 1
}

These assertions are really succinct, and they are packing a lot of power. First of all, we are exhaustively asserting across the entire state of the application. If one small thing changed anywhere in the state that we did not explicitly assert, whether it be in a particular counter in the list, or in the fact prompt, or at the root of the application state, we will instantly get a failure. For example, suppose we claim that when a counter row was incremented it had a count value of 2 instead of 1:

store.send(
  .counterRow(id: id, action: .counter(.incrementButtonTapped))
) {
  $0.counters[id: id]?.counter.count = 1
}

State change does not match expectation: …

  AppState(
    counters: [
      CounterRowState(
        counter: CounterState(
          alert: nil,
−         count: 2
+         count: 1
        ),
        id: 00000000-0000-0000-0000-000000000000
      ),
    ],
    factPrompt: nil
  )

(Expected: −, Actual: +)

We instantly get a failure that points out exactly what part of the state mismatched our expectation.

Let’s change the count back to get a passing.

It’s even further exhaustive on how effects execute and feed their data back into the system. The fact that these two assertions pass means that no effects were executed between sending these two actions, for if an effect executed and feed a new action into the system we would get a failure if we tried sending a new action without first asserting that we received an action from the effect.

For example, if we forget to receive the fact response:

// store.receive(
//   .counterRow(
//     id: id,
//     action: .counter(
//       .factResponse(.success("1 is a good number."))
//     )
//   )
// ) {
//   $0.factPrompt = .init(count: 1, fact: "1 is a good number.")
// }

Must handle 1 received action before sending an action: …

We get a failure because we did not explicitly assert that we expected to receive this action.

Let’s see what it takes to capture just these first two assertions with the vanilla SwiftUI view model. We can start by getting some scaffolding in place for the test:

func testViewModel() {
  let id = UUID(uuidString: "00000000-0000-0000-0000-000000000000")!

  let viewModel = AppViewModel(
    fact: .init(fetch: {
      .init(value: "\($0) is a good number.")
    }),
    mainQueue: .immediate,
    uuid: { id }
  )

}

We can then simulate the user tapping on the add button by simply invoking that method on the view model:

viewModel.addButtonTapped()

And already we have a pretty striking difference between view model testing and testing in the Composable Architecture. This test will pass as-is. And that’s not surprising because all we are doing is invoking a method.

However, the equivalent code in a Composable Architecture test:

store.send(.addButtonTapped)

Fails because we are forced to describe every state change that happened after sending that action:

State change does not match expectation: …

  AppState(
    counters: [
+     CounterRowState(
+       counter: CounterState(
+         alert: nil,
+         count: 0
+       ),
+       id: 00000000-0000-0000-0000-000000000000
+     ),
    ],
    factPrompt: nil
  )

(Expected: −, Actual: +)

But let’s go ahead and add an assertion after invoking the view model method:

XCTAssertEqual(
  <#expression1: Equatable#>,
  <#expression2: Equatable#>
)

Question is, what can as assert against? The viewModel.counters field holds an array of view models, which are reference types, and so it’s tricky to test for equality between reference types. First of all, our CounterRowViewModel doesn’t conform to Equatable so we can’t do this:

XCTAssertEqual(
  viewModel.counters,
  [
  ]
)

Global function ‘XCTAssertEqual(_:_:_:file:line:)’ requires that ‘CounterRowViewModel’ conform to ‘Equatable’

And even if we go make the view model conform to Equatable:

class CounterRowViewModel: ObservableObject, Equatable, Identifiable {

Type ‘CounterRowViewModel’ does not conform to protocol ‘Equatable’

We run into the problem that classes do not get an automatically synthesized Equatable conformance like structs do. There’s probably a good reason for this. Structs are just a simple bag of data, and so equality between values is very straightforward, whereas classes are an amalgamation of data and behavior, and so equality can be a much more subtle distinction.

This means if we want to test equality between view models like we do for state in the Composable Architecture we have to manually implement equality, which is not only going to be a lot of boilerplate but also it’s not even clear how we should implement it. Take for example the CounterViewModel. Not only does it hold data but it also holds dependencies. Do we need to do some kind of equality checking on those? And should we take behavior into consideration when defining equality? Like, should we factor in the fact API request being in flight to distinguish two potentially different view models? These are the kinds of questions I do not want to answer, and so let’s not try to conform view models to the Equatable protocol.

Instead, we will just assert on a few fields that we are interested in. For example, after the add button is tapped we expect the counters array to have a single element, and that element’s count should be 0:

XCTAssertEqual(viewModel.counters.count, 1)
XCTAssertEqual(viewModel.counters[0].counter.count, 0)

This passes, but it’s also not as strong as it could be. If any changes were made out of these two simple checks we will completely miss it in this test, and that’s a bummer.

We can also quickly simulate the user tapping on the increment button in a particular row of the list, and assert on the count after that action:

viewModel.counters[0].counter.incrementButtonTapped()
XCTAssertEqual(viewModel.counters[0].counter.count, 1)

Again, more things outside the count field could have been mutated and we would be none the wiser.

The next interesting segment of assertions in the Composable Architecture test is where we simulate the user tapping the fact button in a particular row of the list, which doesn’t cause any state mutations to happen:

store.send(.counterRow(id: id, action: .counter(.factButtonTapped)))

But, it does cause an effect to be executed, which feeds a response back into the system, which in turn causes the fact prompt to appear:

store.receive(
  .counterRow(
    id: id,
    action: .counter(
      .factResponse(.success("1 is a good number."))
    )
  )
) {
  $0.factPrompt = .init(count: 1, fact: "1 is a good number.")
}

This is showing off the exhaustive effect testing that is possible in the Composable Architecture. If we had not explicitly asserted that we received an action from an effect and the resulting state mutation that happened due to that action we would have gotten a failure. This is a really incredible feature of the library.

On the other hand, for the vanilla SwiftUI view model we can reach into the counters array, grab the view model at the 0th index, and invoke its factButtonTapped method:

viewModel.counters[0].counter.factButtonTapped()

And again this test passes just fine. There are no checks in place to force us to deal with the fact that tapping that button causes a side effect. Luckily we can test the result of the effect because we controlled the fact client and scheduler. We expect that the factPrompt all the way back at the root as flipped to something non-nil:

XCTAssertNotNil(viewModel.factPrompt)

This test passes, and we could strengthen it a bit more by asserting on some of the state inside factPrompt:

XCTAssertEqual(viewModel.factPrompt?.count, 1)
XCTAssertEqual(viewModel.factPrompt?.fact, "1 is a good number.")

This is pretty cool. We are getting some good test coverage on how two completely independent features integrate with each other. The entire counter domain and fact prompt domain could be put into their own modules with no dependency between them whatsoever, all the while letting the AppViewModel integrate the features together. The action that took place deep in the array of view models has had reverberations all the way back at the root, and we get to test the whole thing in one package. That’s really powerful.

The final steps of the Composable Architecture test dismiss the fact prompt, and then simulate tapping the remove button on the counter that was previously added:

store.send(.factPrompt(.dismissButtonTapped)) {
  $0.factPrompt = nil
}

store.send(.counterRow(id: id, action: .removeButtonTapped)) {
  $0.counters = []
}

To do this in the vanilla SwiftUI view model we can invoke the dismissFactPrompt method and make sure that the factPrompt field goes back to nil:

viewModel.dismissFactPrompt()
XCTAssertNil(viewModel.factPrompt)

And then we can further invoke the .removeButtonTapped endpoint and make sure the counters array is emptied:

viewModel.removeButtonTapped(id: id)
XCTAssertEqual(viewModel.counters.count, 0)

So, we’ve now roughly recovered the test suite we had for the Composable Architecture, but just using a plain, vanilla SwiftUI view model. The tests aren’t as strong as they could be because we lose lots of opportunities for exhaustivity, such as asserting on how state changes and how effects are executed.

There’s another weakness in this test suite that’s a little more subtle. Notice that before the two assertions we just wrote we are reaching directly into the AppViewModel to invoke an endpoint to execute some logic:

viewModel.dismissFactPrompt()
viewModel.removeButtonTapped(id: id)

But in the Composable Architecture version we are actually sending actions in the child domains, which are the fact prompt domain and the counter row domain:

store.send(.factPrompt(.dismissButtonTapped))
store.send(.counterRow(id: id, action: .removeButtonTapped))

This may seem like an insignificant distinction, but it actually comes with a big consequence. The reason these endpoints exist on the root AppViewModel rather than their respective view models is because we allowed the child view to communicate to the parent when an action happened, and we did that because it was the easiest way to accomplish what we wanted. However, any communication we perform in the view layer is automatically less testable than if we were to keep things in the observable object layer.

For example, what if we wanted to add some additional behavior to when the user taps the remove button, like tracking an analytics event or something? Then we would want to add a new endpoint to the CounterRowViewModel to handle that behavior:

class CounterRowViewModel: ObservableObject, Identifiable {
  …

  func removeButtonTapped() {
    // TODO: track analytics
  }
}

And we’d want to invoke that method when the remove button is tapped:

Button("Remove") {
  withAnimation {
    self.onRemoveTapped()
    self.viewModel.removeButtonTapped()
  }
}

I guess we have to invoke both functions: one to let the parent know we tapped the remove button and the other to let the domain for the row to execute its own behavior. That’s pretty weird.

Even weirder, we would have to invoke both methods in tests if we wanted to test both the removing logic as well as the analytics behavior:

viewModel.counters[0].removeButtonTapped()
// TODO: assert analytics were tracked
viewModel.removeButtonTapped(id: id)
XCTAssertEqual(viewModel.counters.count, 0)

That looks really bizarre. The whole reason this looks so strange is because we are communicating between child and parent in the view layer instead of the view model layer. It’s possible to refactor the view models so that they communicate directly, and then we could greatly improve the strength of this test.

OK, so that’s testing. We were able to test the same things that we tested in the Composable Architecture, but with weaker guarantees and less exhaustivity.

Vanilla feature iteration

Let’s compare the vanilla SwiftUI and Composable Architecture styles from another angle: how easy it is to add a new feature. Let’s quickly add a row to the the list that simply shows the sum of all the counters being displayed. In the Composable Architecture this is as simple as adding a computed property to compute the sum:

struct AppState: Equatable {
  …

  var sum: Int {
    self.counters.reduce(0) { $0 + $1.counter.count }
  }
}

And then a Text view at the top of the List that displays the counters:

List {
  Text("Sum: \(viewStore.sum)")

  …
}

That’s all there is to it. As we add counters and increment them this sum will instantly update.

For the vanilla SwiftUI view model we can also add a computed property, but this time to the AppViewModel:

class AppViewModel: ObservableObject {
  …

  var sum: Int {
    self.counters.reduce(0) { $0 + $1.counter.count }
  }
}

And we can add a Text view to the top of the List that displays the counters:

Text("Sum: \(viewModel.sum)")

That was quick for vanilla SwiftUI too, but sadly it doesn’t work. If we add some counters and start incrementing them the sum text never updates. This is due to that subtlety we mentioned before where @Published fields do not pick up changes in classes, and hence we are not actually observing the changes to the CounterViewModel’s count.

If we add or remove a counter from the list we will then see the sum update because those operations do trigger the @Published property, thus causing the view to re-render.

In order to support this feature we need to listen for changes inside the CounterViewModel from the AppViewModel, and then manually trigger objectWillChange. To do this we need to insert some additional logic in the addButtonTapped method. We can extract out a little local counter view model so that we can observe it:

func addButtonTapped() {
  let counterViewModel = CounterViewModel(
    onFact: { [weak self] number, fact in
      guard let self = self else { return }
      self.factPrompt = .init(
        count: number,
        fact: fact,
        factClient: self.fact,
        mainQueue: self.mainQueue
      )
    },
    fact: self.fact,
    mainQueue: self.mainQueue
  )

  self.counters.append(
    .init(
      counter: counterViewModel,
      id: self.uuid()
    )
  )
}

And then we can sink on the counter view model’s $count publisher to be notified of when it changes, and use that as an opportunity to ping the app view model’s objectWillChange:

counterViewModel.$count
  .sink { _ in self.objectWillChange.send() }

This returns a cancellable we need to keep track of, which means we have to introduce a set of cancellables to the AppViewModel class:

private var cancellables: Set<AnyCancellable> = []

So that we can store it:

counterViewModel.$count
  .sink { _ in self.objectWillChange.send() }
  .store(in: &self.cancellables)

Now when we run the preview we see the behavior we expect. As we increment and decrement in any row we see the sum row update.

In the Composable Architecture, adding this new feature was quite simple because we were dealing with value types that can be automatically observed by the system via the Store. In vanilla SwiftUI we had to do a little more work since we are dealing with view models as reference types and we need to manually coordinate updates between them.

What’s cool, though, is that both versions of the application were defined in about the same number of lines of code, where the Composable Architecture is only a couple dozen lines longer, but comes with a bunch of tools for composability and testability.

Conclusion

That concludes this series of episodes. We’ve gone on a long journey to explore the concept of “deriving behavior”, which is what we do when we want to build features in isolation as small domains and plug them together to form the larger application domain. We started in vanilla SwiftUI to explore the tools that Apple gives us out of the box. It’s possible, but it can be tricky.

Then we explored the tools that the Composable Architecture gives us, and there was a lot there. At its core it provides pullback for transforming a feature’s logic and scope for transforming a feature’s runtime. These two tools together gives you the ability to break down lots of domains into smaller and smaller domains.

But some domains are complicated enough where pullback and scope don’t cut it. When domains are embedded in data structures, such as collections, optionals, or enums, we need more tools. Some of those tools were already available to users of the library, such as the forEach and optional higher-order reducers and the ForEachStore and IfLetStore views that help transform stores. Other tools, such as pullback along state case paths and the SwitchStore were developed live right in these episodes and then later open sourced.

And then finally we came full circle and decided to try to rebuild an application we made entirely in the Composable Architecture using only the tools Apple gives us in SwiftUI. We found that it is totally possible, and we were even able to achieve isolation, modularity and some test coverage. However, some complexity slipped into the application due to the fact that we are now primarily dealing with reference types rather than value types, and we weren’t able to recover the nice testing abilities of the Composable Architecture.

So, the main point to this exploration is to convince you that it is a worthwhile endeavor to think about how to break down domains into smaller units, and how to approach this problem in both vanilla SwiftUI and the Composable Architecture. Amazingly the code for each implementation is nearly identical, weighing in at a little over 300 lines of code each, so you’ll be doing just fine with either approach.

Until next time!


Downloads

Get started with our free plan

Our free plan includes 1 subscriber-only episode of your choice, access to 64 free episodes with transcripts and code samples, and weekly updates from our newsletter.

View plans and pricing