Exploring The Composable Architecture

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:

  1. Todo: the logic for interacting with a single todo item
  2. 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 a viewStore 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.

Todo cell

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 of Todo 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:

  1. a closure that is responsible for creating a UUID, we will create different closures for production and test, more on this later.
  2. 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!
Moving todo rows all together

swift

struct TodosEnvironment { var uuid: () -> UUID var scheduler: AnySchedulerOf<DispatchQueue> }

The reducer is a combination of two reducers here:

  1. 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.

  2. 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 the TodosAction.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 👋


  • Swift
  • iOS
  • SwiftUI
  • Tutorial
  • Composable Architecture