Featured image of post The Composable Architecture - Part 1

The Composable Architecture - Part 1

What is The Composable Architecture? How to use it in your app? Let's find out in this blog post.

Introduction

The Composable Architecture (TCA, for short) is a library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind. It can be used in SwiftUI, UIKit, and more, and on any Apple platform (iOS, macOS, tvOS, and watchOS).

This library provides a few core tools that can be used to build applications of varying purpose and complexity. It provides compelling stories that you can follow to solve many problems you encounter day-to-day when building applications, such as:

  • State management: How to manage the state of your application using simple value types, and share state across many screens so that mutations in one screen can be immediately observed in another screen.

  • Composition: How to break down large features into smaller components that can be extracted to their own, isolated modules and be easily glued back together to form the feature.

  • Side effects: How to let certain parts of the application talk to the outside world in the most testable and understandable way possible.

  • Testing: How to not only test a feature built in the architecture, but also write integration tests for features that have been composed of many parts, and write end-to-end tests to understand how side effects influence your application. This allows you to make strong guarantees that your business logic is running in the way you expect.

  • Ergonomics: How to accomplish all of the above in a simple API with as few concepts and moving parts as possible.

Basic Usage

To build a feature using the Composable Architecture you define some types and values that model your domain:

  • State: A type that describes the data your feature needs to perform its logic and render its UI.

  • Action: A type that represents all of the actions that can happen in your feature, such as user actions, notifications, event sources and more.

  • Reducer: A function that describes how to evolve the current state of the app to the next state given an action. The reducer is also responsible for returning any effects that should be run, such as API requests, which can be done by returning an Effect value.

  • Store: The runtime that actually drives your feature. You send all user actions to the store so that the store can run the reducer and effects, and you can observe state changes in the store so that you can update UI.

TCA Diagram

The First TCA App

Let’s build a simple counter app using TCA. The app will have two buttons, one to increment the counter and one to decrement the counter. The app will also have a reset button to reset the counter to zero.

Preqrequisites

  • The Composable Architecture 1.7.0.
  • iOS 17.0+

Create a new project

  • Open Xcode and create a new project.
  • Add The Composable Architecture package to the project. Go to File > Swift Packages > Add Package Dependency... and enter the following URL: https://github.com/pointfreeco/swift-composable-architecture.

Create the UI

  • Let’s create the UI for the app like below: Counter App

  • Open ContentView.swift and replace the code with the following code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import SwiftUI

struct ContentView: View {
  @State private var isTimerOn = false

  var body: some View {
    Form {
      Section {
        Text("0")
        Button("Decrement") {
        }
        Button("Increment") {
        }
      }
      Section {
        Button {
        } label: {
          HStack {
            Text("Get fact")
          }
        }
        Text("Some fact")
      }
      Section {
        if isTimerOn {
          Button("Stop timer") {
          }
        } else {
          Button("Start timer") {
          }
        }
      }
    }
  }
}

Start building with TCA

  • First we need access to the Composable Architecture, so let’s import it:
1
import ComposableArchitecture
  • And now we can start building our feature. We create a new type that will house the domain and behavior of the feature, and it will be annotated with the @Reducer macro:
1
2
3
@Reducer
struct CounterReducer {
}
  • We can even just look at the view to see exactly what is needed: a integer for the current count, an optional string for the fact if it is being shown, and a boolean that determines whether or not a timer is currently on:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@Reducer
struct CounterReducer {
  @ObservableState
  struct State: Equatable {
    var count = 0
    var numberFact: String?
    var isLoadingFact = false
    var isTimerOn = false
  }
}
  • Next we need to define the actions that can happen in this view. This is almost always an enum:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@Reducer
struct CounterReducer {
  @ObservableState
  struct State: Equatable { ... }

  enum Action {
    case decrementButtonTapped
    case incrementButtonTapped
    case numberFactButtonTapped
    case numberFactResponse(String)
    case toggleTimerButtonTapped
    case timerTicked
  }
}
  • And then we implement the body property, which is responsible for composing the actual logic and behavior for the feature. In it we can use the Reduce reducer to describe how to change the current state to the next state, and what effects need to be executed. Some actions don’t need to execute effects, and they can return .none to represent that:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
@Reducer
struct CounterReducer {
  @ObservableState
  struct State: Equatable { ...}

  enum Action { ...}

  private enum CancelID {
    case timer
  }

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .decrementButtonTapped:
        state.count -= 1
        state.numberFact = nil
        return .none

      case .incrementButtonTapped:
        state.count += 1
        state.numberFact = nil
        return .none

      case .numberFactButtonTapped:
        state.numberFact = nil
        state.isLoadingFact = true
        return .run { [count = state.count] send in
          try await Task.sleep(for: .seconds(1))
          let (data, _) = try await URLSession.shared.data(
            from: URL(string: "http://numbersapi.com/\(count)/trivia")!
          )
          await send(.numberFactResponse(String(decoding: data, as: UTF8.self)))
        }

      case .numberFactResponse(let fact):
        state.numberFact = fact
        state.isLoadingFact = false
        return .none

      case .toggleTimerButtonTapped:
        state.isTimerOn.toggle()
        if state.isTimerOn {
          return .run { send in
            while true {
              try await Task.sleep(for: .seconds(1))
              await send(.timerTicked)
            }
          }
          .cancellable(id: CancelID.timer)
        } else {
          return .cancel(id: CancelID.timer)
        }

      case .timerTicked:
        state.count += 1
        return .none
      }
    }
  }
}
  • And then finally we define the view that displays the feature. It holds onto a StoreOf<CounterReducer> so that it can observe all changes to the state and re-render, and we can send all user actions to the store so that state changes:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
struct ContentView: View {
  let store: StoreOf<CounterReducer>

  var body: some View {
    Form {
      Section {
        Text("\(store.count)")
        Button("Decrement") {
          store.send(.decrementButtonTapped)
        }
        Button("Increment") {
          store.send(.incrementButtonTapped)
        }
      }

      Section {
        Button {
          store.send(.numberFactButtonTapped)
        } label: {
          HStack {
            Text("Get fact")
            if store.isLoadingFact {
              Spacer()
              ProgressView()
            }
          }
        }
        if let fact = store.numberFact {
          Text(fact)
        }
      }

      Section {
        if store.isTimerOn {
          Button("Stop timer") {
            store.send(.toggleTimerButtonTapped)
          }
        } else {
          Button("Start timer") {
            store.send(.toggleTimerButtonTapped)
          }
        }
      }
    }
  }
}

Testing your TCA app

  • Let’s write some tests for the app.
  • Create your first test and add the following code:
1
2
3
4
5
6
7
@MainActor
func testCounter() async {
  let store = TestStore(initialState: CounterReducer.State()) {
    CounterReducer()
  }
  await store.send(.incrementButtonTapped)
}
  • Run the test and you will see the following error:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
State was not expected to change, but a change occurred: …

      CounterReducer.State(
    βˆ’   _count: 0,
    +   _count: 1,
        _numberFact: nil,
        _isLoadingFact: false,
        _isTimerOn: false
      )

(Expected: βˆ’, Actual: +)
  • To fix this we need to mutate $0 to make its count 1:
1
2
3
4
5
6
7
8
func testCounter() async {
  let store = TestStore(initialState: CounterReducer.State()) {
    CounterReducer()
  }
  await store.send(.incrementButtonTapped) {
    $0.count = 1
  }
}
  • Now the test passes.

Testing with dependencies

  • In tests we can use a mock dependency that immediately returns a deterministic, predictable fact. We can start by wrapping the number fact functionality in a new type:
1
2
3
struct NumberFactClient {
  var fetch: (Int) async throws -> String
}
  • And then registering that type with the dependency management system by conforming the client to the DependencyKey protocol, which requires you to specify the live value to use when running the application in simulators or devices:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
extension NumberFactClient: DependencyKey {
  static let liveValue = Self(
    fetch: { number in
      let (data, _) = try await URLSession.shared
        .data(from: URL(string: "http://numbersapi.com/\(number)")!
      )
      return String(decoding: data, as: UTF8.self)
    }
  )
}


extension DependencyValues {
  var numberFact: NumberFactClient {
    get { self[NumberFactClient.self] }
    set { self[NumberFactClient.self] = newValue }
  }
}
  • And then we can update the reducer to use this dependency:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@Dependency(\.numberFact) private var numberFact

var body: some ReducerOf<Self> {
  Reduce { state, action in
    switch action {
    ...
    case .numberFactButtonTapped:
      state.numberFact = nil
      state.isLoadingFact = true
      return .run { [count = state.count] send in
        let fact = try await numberFact.fetch(count)
        await send(.numberFactResponse(fact))
      }
    ...
  }
}
  • And the test store can be constructed without specifying any dependencies, but you can still override any dependency you need to for the purpose of the test:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func testCounter() async {
  let store = TestStore(initialState: CounterReducer.State()) {
    CounterReducer()
  } withDependencies: {
    $0.numberFact.fetch = { "\($0) is a good number Brent" }
  }

  await store.send(.numberFactButtonTapped) {
    $0.isLoadingFact = true
  }

  await store.receive(\.numberFactResponse) {
    $0.isLoadingFact = false
    $0.numberFact = "0 is a good number Brent"
  }
}

Conclusion

References

comments powered by Disqus
Built with Hugo
Theme Stack designed by Jimmy