We first began discussing application architecture nearly 9 months ago, and in that time we have built up a comprehensive story of how one can build applications in Swift for Apple’s platforms in a consistent and understandable way. We focused on a few key topics:
State management: How can we build the majority of our application using simple value types, and how can disparate parts of the application communicate with each other by sharing state?
Composition: How can we break a large, complex feature down into smaller pieces that glue together to form the whole?
Modularity: After having broken down a large problem into small ones, how can we put each of the pieces in their own modules, with as few dependencies as possible between them, so that we can run features in isolation without having to build the entire application?
Side effects: How can we let our features communicate with the outside world, and vice-versa, in an understandable and composable way?
Testing: How can we accomplish the above without sacrificing testability? We want to test our features so that it doesn’t take a lot of work to set up a test, and so that we can test very deep properties of our system, including how effects are executed and how their outputs are fed back into the feature.
Dependency Management: How can we bake the notion of dependencies directly into the architecture so that each feature has all of the tools necessary to implement its logic. This not only helps with testing since it becomes very easy to mock out all the dependencies a feature needs, but also aids in modularity and compile times since features can be broken out into modules that depend on only the bare essentials.
Adaptability: How can implement the core business logic of our features a single time, while still allowing that logic to be used on multiple platforms, such as iOS, macOS, watchOS and tvOS?
And today we are excited to announce that we are finally open-sourcing the Composable Architecture, 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 applications, and more, and on any Apple platform (iOS, macOS, tvOS, and watchOS).
We have also released the first part of a multipart tour of the library, and it’s completely 🆓 for everyone to watch. Enjoy!
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.
Environment: A type that holds any dependencies the feature needs, such as API clients, analytics clients, etc.
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.
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.
The benefits of doing this is that you will instantly unlock testability of your feature, and you will be able to break large, complex features into smaller domains that can be glued together.
As a basic example, consider a UI that shows a number along with “+” and “−” buttons that increment and decrement the number. To make things interesting, suppose there is also a button that when tapped makes an API request to fetch a random fact about that number and then displays the fact in an alert.
The state of this feature would consist of an integer for the current count, as well as an optional string that represents the title of the alert we want to show (optional because nil
represents not showing an alert):
struct AppState {
var count = 0
var numberFactAlert: String?
}
Next we have the actions in the feature. There are the obvious actions, such as tapping the decrement button, increment button, or fact button. But there are also some slightly non-obvious ones, such as the action of the user dismissing the alert, and the action that occurs when we receive a response from the fact API request:
enum AppAction {
case factAlertDismissed
case decrementButtonTapped
case incrementButtonTapped
case numberFactButtonTapped
case numberFactResponse(Result<String, ApiError>)
}
struct ApiError: Error {}
Next we model the environment of dependencies this feature needs to do its job. In particular, to fetch a number fact we need to construct an Effect
value that encapsulates the network request. So that dependency is a function from Int
to Effect<String, ApiError>
, where String
represents the response from the request. Further, the effect will typically do its work on a background thread (as is the case with URLSession
), and so we need a way to receive the effect’s values on the main queue. We do this via a main queue scheduler, which is a dependency that is important to control so that we can write tests. We must use an AnyScheduler
so that we can use a live DispatchQueue
in production and a test scheduler in tests.
struct AppEnvironment {
var mainQueue: AnySchedulerOf<DispatchQueue>
var numberFact: (Int) -> Effect<String, ApiError>
}
Next, we implement a reducer that implements the logic for this domain. It describes how to change the current state to the next state, and describes what effects need to be executed. Some actions don’t need to execute effects, and they can return .none
to represent that:
let appReducer = Reducer<
AppState, AppAction, AppEnvironment
> { state, action, environment in
switch action {
case .factAlertDismissed:
state.numberFactAlert = nil
return .none
case .decrementButtonTapped:
state.count -= 1
return .none
case .incrementButtonTapped:
state.count += 1
return .none
case .numberFactButtonTapped:
return environment.numberFact(state.count)
.receive(on: environment.mainQueue)
.catchToEffect()
.map(AppAction.numberFactResponse)
case let .numberFactResponse(.success(fact)):
state.numberFactAlert = fact
return .none
case let .numberFactResponse(.failure):
state.numberFactAlert = "Could not load a number fact :("
return .none
}
}
And then finally we define the view that displays the feature. It holds onto a Store<AppState, AppAction>
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. We must also introduce a struct wrapper around the fact alert to make it Identifiable
, which the .alert
view modifier requires:
struct AppView: View {
let store: Store<AppState, AppAction>
var body: some View {
WithViewStore(store) { viewStore in
VStack {
HStack {
Button("−") { viewStore.send(.decrementButtonTapped) }
Text("\(viewStore.count)")
Button("+") { viewStore.send(.incrementButtonTapped) }
}
Button("Number fact") {
viewStore.send(.numberFactButtonTapped)
}
}
.alert(
item: viewStore.binding(
get: { $0.numberFactAlert.map(FactAlert.init(title:)) },
send: .factAlertDismissed
),
content: { Alert(title: Text($0.title)) }
)
}
}
}
struct FactAlert: Identifiable {
var title: String
var id: String { title }
}
It’s important to note that we were able to implement this entire feature without having a real, live effect at hand. This is important because it means features can be built in isolation without building their dependencies, which can help compile times.
It is also straightforward to have a UIKit controller driven off of this store. You subscribe to the store in viewDidLoad
in order to update the UI and show alerts. The code is a bit longer than the SwiftUI version, so we have collapsed it here:
Click to expand!
class AppViewController: UIViewController {
let viewStore: ViewStore<AppState, AppAction>
var cancellables: Set<AnyCancellable> = []
init(store: Store<AppState, AppAction>) {
self.viewStore = ViewStore(store)
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
let countLabel = UILabel()
let incrementButton = UIButton()
let decrementButton = UIButton()
let factButton = UIButton()
… // Omitted: Add subviews and set up constraints...
viewStore.publisher
.map { "\($0.count)" }
.assign(to: \.text, on: countLabel)
.store(in: &cancellables)
viewStore.publisher.numberFactAlert
.sink { [weak self] numberFactAlert in
let alertController = UIAlertController(
title: numberFactAlert,
message: nil,
preferredStyle: .alert
)
alertController.addAction(
UIAlertAction(
title: "OK",
style: .default,
) { _ in
self?.viewStore.send(.factAlertDismissed)
}
)
self?.present(
alertController, animated: true, completion: nil
)
}
.store(in: &cancellables)
}
@objc private func incrementButtonTapped() {
viewStore.send(.incrementButtonTapped)
}
@objc private func decrementButtonTapped() {
viewStore.send(.decrementButtonTapped)
}
@objc private func factButtonTapped() {
viewStore.send(.numberFactButtonTapped)
}
}
Once we are ready to display this view, for example in the scene delegate, we can construct a store. This is the moment where we need to supply the dependencies, and for now we can just use an effect that immediately returns a mocked string:
let appView = AppView(
store: Store(
initialState: AppState(),
reducer: appReducer,
environment: AppEnvironment(
mainQueue: DispatchQueue.main.eraseToAnyScheduler(),
numberFact: { number in
Effect(value: "\(number) is a good number Brent")
}
)
)
)
And that is enough to get something on the screen to play around with. It’s definitely a few more steps than if you were to do this in a vanilla SwiftUI way, but there are a few benefits. It gives us a consistent manner to apply state mutations, instead of scattering logic in some observable objects and in various action closures of UI components. It also gives us a concise way of expressing side effects. And we can immediately test this logic, including the effects, without doing much additional work.
To test, you first create a TestStore
with the same information that you would to create a regular Store
, except this time we can supply test-friendly dependencies. In particular, we use a test scheduler instead of the live DispatchQueue.main
scheduler because that allows us to control when work is executed, and we don’t have to artificially wait for queues to catch up.
let scheduler = DispatchQueue.testScheduler
let store = TestStore(
initialState: AppState(),
reducer: appReducer,
environment: AppEnvironment(
mainQueue: scheduler.eraseToAnyScheduler(),
numberFact: { number in
Effect(value: "\(number) is a good number Brent")
}
)
)
Once the test store is created we can use it to make an assertion of an entire user flow of steps. Each step of the way we need to prove that state changed how we expect. Further, if a step causes an effect to be executed, which feeds data back into the store, we must assert that those actions were received properly.
The test below has the user increment and decrement the count, then they ask for a number fact, and the response of that effect triggers an alert to be shown, and then dismissing the alert causes the alert to go away.
store.assert(
// Test that tapping on the increment/decrement buttons
// changes the count
.send(.incrementButtonTapped) {
$0.count = 1
},
.send(.decrementButtonTapped) {
$0.count = 0
},
// Test that tapping the fact button causes us to receive
// a response from the effect.
.send(.numberFactButtonTapped),
// Note that we have to advance the scheduler because we
// used `.receive(on:)` in the reducer.
.do { scheduler.advance() },
.receive(
.numberFactResponse(.success("0 is a good number Brent"))
) {
$0.numberFactAlert = "0 is a good number Brent"
},
// And finally dismiss the alert
.send(.factAlertDismissed) {
$0.numberFactAlert = nil
}
)
That is the basics of building and testing a feature in the Composable Architecture. There are a lot more things to be explored, such as composition, modularity, adaptability, and complex effects. The Examples directory has a bunch of projects to explore to see more advanced usages.
So, that’s the basics of the Composable Architecture. There is a ton more to say about what the library is capable of, and the repo contains lots of case studies and demos showing how to solve common application problems in isolation. We hope you’ll it out, and let us know what you think!