Exploring The Composable Architecture
Photo by Ashkan Forouzani on Unsplash
Exploring The Composable Architecture (TCA) - Part 1
In this series of posts, I'll be exploring the Composable Architecture by building some apps. To start, let's build the simple todos app —Explained in detail in these episodes by the creators—
The Composable Architecture (TCA, for short) is a library for building applications consistently and understandably, 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).
Think of each screen as a small app, that can be run, tested, and debugged on its own, while at the end being able to compose it with other screens to form a full complex app.
TCA building blocks
I noticed many similarities with TCA and Redux for React, the idea is each screen should have the following building blocks which can be later composed in larger units that form a complex app.
State
A type that holds the current state of the application. usually represented as a struct.
Action
A type that holds all possible actions that cause the state of the application to change. usually represented as an enum which —in combination with a switch— breaks in build time if one of the cases is not handled.
Effect
A type that encapsulates a unit of work that can be run in the outside world, and can feed data back to the Store. such as network requests, saving/loading from disk, creating timers, etc.
Environment
A type that holds all dependencies needed to produce Effects, such as API clients, analytics clients, random number generators, etc.
Reducer
Think of the reducer as the brain that describes how to evolve the current state of an application to the next state, given some action, and describes what effects should be executed later by the store —if any—.
Store
A store represents the runtime that powers the application. It is the object that you will pass around to views that need to interact with the application.
Back to our todos app, the app is a one-screen app, but could be divided into two main pieces:
- Todo: the logic for interacting with a single todo item
- Todos: the logic for interacting with a list of todo items.
1. Todo
The state for a single todo item is a simple struct that represents what a todo is.
swift
struct Todo: Equatable, Identifiable {
var id: UUID
var description = ""
var isComplete = false
}
The action is an enum representing all actions that can be performed on a single todo item.
swift
enum TodoAction: Equatable {
case checkBoxToggled
case textFieldChanged(String)
}
The environment is a place that stores all external dependencies required to create a todo, for now, it's going to be an empty struct.
swift
struct TodoEnvironment {}
The reducer is the brain that contains all business logic for manipulating the state, it takes an
inout
state, an action to perform, and an environment to get external dependencies from.The reducer can then emit effects when an action is performed, in this case, both actions do not emit any effects.
swift
let todoReducer = Reducer<Todo, TodoAction, TodoEnvironment> { state, action, env in
switch action {
case .checkBoxToggled:
state.isComplete.toggle()
return .none
case .textFieldChanged(let description):
state.description = description
return .none
}
}
The view needs a store to get state from and perform actions on. notice how the UI is wrapped in a
WithViewStore
wrapper, this ensures the view is only updated when there is a state change and provides aviewStore
property that can be used to get state and send actions to the store to perform.Unlike plain SwiftUI with Combine, all state changes should be handled by the store, so to provide a binding string to the text field, TCA comes with a
binding
method that defines a getter and setter for a given value.
swift
struct TodoView: View {
let store: Store<Todo, TodoAction>
var body: some View {
WithViewStore(store) { viewStore in
HStack {
Button(action: { viewStore.send(.checkBoxToggled) }) {
Image(systemName: viewStore.isComplete ? "checkmark.square" : "square")
}
.buttonStyle(.plain)
TextField(
"Untitled Todo",
text: viewStore.binding(
get: \.description,
send: TodoAction.textFieldChanged
)
)
}
.foregroundColor(viewStore.isComplete ? .gray : nil)
}
}
}
2. Todos
The model for the todos screen is a struct that has an
IdentifiedArray
ofTodo
items, this helps the view to loop over all items and connect them to a list, more on this later.
swift
struct TodosState: Equatable {
var todos: IdentifiedArrayOf<Todo> = []
}
Like before, the action is an enum representing all actions that can be performed in the todos screen. Notice how the last one takes an id for a todo and a single todo action to perform on it!
swift
enum TodosAction: Equatable {
case addTodoButtonTapped
case delete(IndexSet)
case sortCompletedTodos
case todo(id: Todo.ID, action: TodoAction)
}
This is an amazing feature of TCA, the environment provides two things:
- a closure that is responsible for creating a UUID, we will create different closures for production and test, more on this later.
- a scheduler that we'll use to delay sorting the checked todos, think of this as a
DispatchQueue
that we will be able to swap in tests with a test scheduler, the environment enables us to control time!
swift
struct TodosEnvironment {
var uuid: () -> UUID
var scheduler: AnySchedulerOf<DispatchQueue>
}
The reducer is a combination of two reducers here:
The single todo reducer pulled back to work on each item on the todos list. Notice the
\TodosAction.todo
, it's a case path, think of it as a key path but to enums.a new reducer that handles all other actions in the screen, however, we're also handling the
.todo(.checkBoxToggled)
case here to emit an effect to wait 1 second after the last checkbox is toggled and sort the todos all at once for a better user experience.
While we could have used a simple string for the debounced id here, using a private
Hashable
type ensures that the effect can not be mixed with other debounced effects throughout the app.
swift
let todosReducer = Reducer<TodosState, TodosAction, TodosEnvironment>.combine(
todoReducer.forEach(
state: \.todos,
action: /TodosAction.todo,
environment: { _ in TodoEnvironment() }
),
.init { state, action, env in
switch action {
case .addTodoButtonTapped:
state.todos.insert(Todo(id: env.uuid()), at: 0)
return .none
case .delete(let indexSet):
state.todos.remove(atOffsets: indexSet)
return .none
case .sortCompletedTodos:
state.todos.sort { !$0.isComplete && $1.isComplete }
return .none
case .todo(let id, action: .checkBoxToggled):
struct TodoCompletionId: Hashable {}
return Effect(value: .sortCompletedTodos)
.debounce(id: TodoCompletionId(), for: 1, scheduler: env.scheduler)
case .todo:
return .none
}
}
)
Like the
TodoView
, the view needs a store to get state from and perform actions on.Notice how the list uses a
ForEachStore
that scopes the store to a sub-store for each todo item providing it with a single todo state, and pulling back its action to theTodosAction.todo
case!
swift
struct TodosView: View {
let store: Store<TodosState, TodosAction>
var body: some View {
NavigationView {
WithViewStore(store) { viewStore in
List {
ForEachStore(
store.scope(state: \.todos, action: TodosAction.todo),
content: TodoView.init
)
.onDelete { viewStore.send(.delete($0)) }
}
.navigationTitle("Todos")
.toolbar {
Button("Add Todo") { viewStore.send(.addTodoButtonTapped) }
}
}
}
}
}
Putting everything together
Stating the app becomes as easy as creating a store and passing it to the TodosView
!
swift
@main
struct TodosApp: App {
let store = Store(
initialState: TodosState(),
reducer: todosReducer,
environment: TodosEnvironment(
scheduler: .main,
uuid: UUID.init
)
)
var body: some Scene {
WindowGroup {
TodosView(store: store)
}
}
}
One nice thing we have here is replacing the entry point for the app with the single todo screen can be done effortlessly by changing the store with a single todo store, and passing it to a TodoView
instead, this also enables us to create slices of the app and distribute it easily in more complex apps.
Chained Reducers
We saw before how we combined two reducers in the todos screen, TCA promotes combining many reducers into a single one by running each one on the state in order and merging all of the effects.
This enables endless use cases like the debug
reducer that comes with TCA, which prints debug messages describing all received actions and state mutations, simple by chaining it to any reducer!
Chaining it with the todoReducer
will print the following debug messages in the console
swift
todoReducer
.debug()
received action:
TodoAction.checkBoxToggled
Todo(
id: UUID(54BA9336-DBFE-42BF-8C8A-25AB430611FC),
description: "Buy milk",
- isComplete: false
+ isComplete: true
)
You can create a reducer that logs data, cache responses, send analytics events! Let's create a reducer that caches the state after each update, so when we restart the app we get it right back where it was before quitting!
Building a reducer to persist app state
To start we will make both Todo
and TodosState
conform to Codable
.
swift
struct Todo: Equatable, Identifiable, Codable {
// ...
}
struct TodosState: Equatable, Codable {
// ...
}
Next, let's define a Caching
protocol that represents an object that can save Codable
objects with a given key.
swift
protocol Caching {
var key: String { get }
func save<Value: Encodable>(_ value: Value)
func load<Value: Decodable>() -> Value?
}
We can then create a UserDefaultsCache
which saves the state to UserDefaults
swift
final class UserDefaultsCache: Caching {
init(
key: String,
decoder: JSONDecoder = .init(),
encoder: JSONEncoder = .init()
) {
guard let userDefaults = UserDefaults(suiteName: key) else {
fatalError("Unable to create store with key: \(key)")
}
self.key = key
self.userDefaults = userDefaults
self.decoder = decoder
self.encoder = encoder
}
let key: String
let decoder: JSONDecoder
let encoder: JSONEncoder
let userDefaults: UserDefaults
func save<Value: Encodable>(_ value: Value) {
guard let data = try? encoder.encode(value) else { return }
userDefaults.set(data, forKey: key)
}
func load<Value: Decodable>() -> Value? {
guard let data = userDefaults.data(forKey: key) else { return nil }
return try? decoder.decode(Value.self, from: data)
}
}
Or a DocumentsCache
that saves the state to the documents directory
swift
final class DocumentsCache: Caching {
init(
key: String,
fileManager: FileManager = .default,
decoder: JSONDecoder = .init(),
encoder: JSONEncoder = .init()
) {
self.key = key
self.decoder = decoder
self.encoder = encoder
self.fileUrl = fileManager
.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent("\(key).json")
}
let key: String
let decoder: JSONDecoder
let encoder: JSONEncoder
let fileUrl: URL
func save<Value: Encodable>(_ value: Value) {
let data = try? encoder.encode(value)
try? data?.write(to: fileUrl)
}
func load<Value: Decodable>() -> Value? {
guard let data = try? Data(contentsOf: fileUrl) else { return nil }
return try? decoder.decode(Value.self, from: data)
}
}
Or another one that saves it to a CoreData or realm database ...
See how the reducer is generic enough that can be used on any
Codable
state!
Creating the caching
Reducer
We can extend the Reducer
where its State
conforms to Codable
, and provide a caching(cache:)
function that takes any Caching
store and uses it to save the state every time it changes.
Using the fireAndForget
, we're asking the reducer to fire an effect in the background, where we don't care about getting the result back to the store.
swift
extension Reducer where State: Codable {
func caching(cache: Caching) -> Reducer {
return .init { state, action, environment in
let effects = self.run(&state, action, environment)
let state = state
return .merge(
.fireAndForget {
cache.save(state)
},
effects
)
}
}
}
Even nicer we can provide an optional isDuplicate
closure that compares the state before and after performing an action and save the state only when it changes!
swift
extension Reducer where State: Codable {
func caching(
cache: Caching,
ignoreCachingDuplicates isDuplicate: ((State, State) -> Bool)? = nil
) -> Reducer {
return .init { state, action, environment in
let previousState = state
let effects = self.run(&state, action, environment)
let nextState = state
if isDuplicate?(previousState, nextState) == true {
return effects
}
return .merge(
.fireAndForget {
cache.save(nextState)
},
effects
)
}
}
}
Imagine we have many more properties in the
TodosState
and all we care about is the todos list, this will ignore caching state changes that do not affect the todos list.
swift
todosReducer
.caching(
cache: DocumentsCache(key: "todos"),
ignoreCachingDuplicates: { $0.todos == $1.todos }
)
We're almost done, all we need to do is to load the state from the cache and pass it to the store's initial state, and make sure the same cache is chained to the store's reducer!
swift
@main
struct TodosApp: App {
func createStore() -> Store<TodosState, TodosAction> {
let cache = DocumentsCache(key: "todos")
return .init(
initialState: cache.load() ?? TodosState(),
reducer: todosReducer.caching(
cache: cache,
ignoreCachingDuplicates: { $0.todos == $1.todos }
),
environment: TodosEnvironment(
scheduler: .main,
uuid: UUID.init
)
)
}
var body: some Scene {
WindowGroup {
TodosView(store: createStore())
}
}
}
Testing
Great testability is one of TCA's great features! Since all state mutations are happening in reducers, it is easy to test almost everything in the app with simple unit tests!
Those Environment objects provide a quick way to swap production dependencies with test ones.
To start, let's create a deterministic, auto-incrementing "UUID" generator for testing.
swift
extension UUID {
static var incrementing: () -> UUID {
var uuid = 0
return {
defer { uuid += 1 }
return UUID(uuidString: "00000000-0000-0000-0000-\(String(format: "%012x", uuid))")!
}
}
}
Here is an example of how to test adding a new todo item
TCA comes with a TestStore
class that has a closure in its send
method that can be used to verify state changes.
swift
func testAddTodo() {
let state = TodosState()
let store = TestStore(
initialState: state,
reducer: todosReducer,
environment: TodosEnvironment(
scheduler: DispatchQueue.test.eraseToAnyScheduler(),
uuid: UUID.incrementing
)
)
store.send(.addTodoButtonTapped) {
$0.todos.insert(
Todo(
id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!,
description: "",
isComplete: false
),
at: 0
)
}
}
What's Next?
TCA looks very promising, I enjoyed working with it 😊 and can't wait to use it in more complex apps to see how it plays with more advanced topics like navigation and UIKit.
The code above is a simpler version of the full Todos app with some bells and whistles and more tests available on Github.
That’s it for now. If you have any questions, suggestions, or feedback, please let me know via Twitter 👋