The Composable Architecture iOS library: intro & challenges

Specialist working on the laptop
Specialist working on the laptop

Blog post

When it comes to iOS architectures, we frequently encounter well-known patterns like MVCMVPMVVM, or VIPER. And if you think that we already have enough architectures to choose from, get ready to learn about another one: the Composable Architecture, or TCA for short. 

Composable architecture is primarily a theoretical abstraction that can be used in any language or technology. However, in the context of iOS development, the Composable Architecture (TCA) serves as both an architectural pattern and a practical library tailored to streamline its implementation in Swift and SwiftUI.

Composable architecture as a design philosophy

Composable architecture is a design approach that emphasizes breaking down complex systems into smaller, reusable components. It encourages the creation of modular components or building blocks. Each module or component should have a well-defined purpose and a clear interface. Composable architecture as a design approach goes beyond Swift and iOS; it’s a design philosophy that can be applied in various domains to build flexible, maintainable, and scalable software systems.

It has been named ‘composable’ because the components themselves are the most important part since they are the building blocks within such an architecture. Well-defined interfaces or protocols are also crucial because they define how components can communicate and interact with each other. They establish a contract for how components should work together.

Composable Architecture library in Swift

So, you like the idea of composable architecture, and you would like to structure your iOS applications with it? Point-Free, a platform renowned for its commitment to iOS development education, provides a library known as 'the Composable Architecture.' Point-Free designed the TCA library specifically to implement composable architecture patterns in iOS applications. It offers structured implementation and encourages the use of conventions and best practices for component organization. 

Let’s see a simple example that illustrates how TCA library can help us with various challenges when creating a screen.

In a traditional SwiftUI application without TCA, you might handle state like this:

struct TaskList: View {
    @State private var tasks: [Task] = []
    
    var body: some View {
        VStack {
            List {
                ForEach(tasks) { task in
                    Text(task.title)
                }
            }
            Button("Add Task") {
                tasks.append(Task(id: UUID(), title: "New Task"))
            }
        }
    }
}

In this example, the state is managed directly within the view, and you manually update it when changes occur. While this approach works for simple scenarios, it can become challenging to manage state, handle complex updates, and maintain a clear separation of concerns as your application grows. With TCA, you can tackle these problems more effectively:

We are going to create a reducer first. 

Reducer is a core concept that plays a pivotal role in managing state and handling the state transitions within the application. Reducer contains three required elements:

  • State represents the data that the screen is managing, such as UI state, user data, or any other relevant information. 
  • Action is a value or enum that represents an intent or change in the application's state. Actions are the primary way of triggering state transitions and represent user interactions or events. 
  • Reducer function is the heart of TCA. It's a function that takes the current state, an action, and an environment (such as dependencies or services). The reducer function can update the state according to the current state and action and trigger another action, such as an API call or saving data to disk. Such actions are called side effects, and they are denoted with the return statement within the reducer function. Side effects are not mandatory and we can return .none in the reducer function to denote that we don't need side effects.

The first step to writing your TCA feature would be to create a new type that will house the domain and behavior of the feature. That type would be annotated with the powerful @Reducer macro that helps us get rid of some boilerplate code.

@Reducer
struct TaskListFeature {
}

Here we need to define a type for the feature’s state, which would consist of an array of tasks. With TCA, we typically use IdentifiedArray instead of a plain Array since it solves some problems with asynchronous mutations of the wrong elements and crashes. The IdentifiedArray is included in the Swift-Identified-Collections library, which is part of the TCA framework. The state would also be annotated with the @ObservableState macro, allowing us to take advantage of all the observation tools in the library.

@Reducer
struct TaskListFeature {
   @ObservableState
   struct State: Equatable {
var tasks: IdentifiedArrayOf<Task> = []
   }
}

We also need to define a type for the feature’s actions. In our example, there would be just one action for adding a new task.

@Reducer
struct TaskListFeature {
   @ObservableState
   struct State: Equatable { ..... }
   enum Action { 
      case addTask
   }
}

Then we implement the body property, which is responsible for composing the actual logic and behavior of the feature. We would use the Reduce reducer to describe how our action would mutate the state. Since we currently have just one simple action, it would return .none to represent that we don’t need a side effect. 

@Reducer
struct TaskListFeature {
   @ObservableState
   struct State: Equatable { ..... }
   enum Action { ..... }

   var body: some ReducerOf<Self> { // Looks familiar? Just like SwiftUI body :)
      Reduce { state, action in 
         switch action {
            case addTask: 
               state.tasks.append(Task(id: UUID(), title: "New task"))
               return .none // here we can return .run {} if we need to use some external service or run asynchronous task
         }
      }
   }
}

Our TaskListView would require some modifications. Instead of using the @State property within the view, it would hold onto a StoreOf<TaskListFeature> so it can observe all the changes of the state and re-render and send actions in order to change the state. 

struct TaskListView: View {
    let store: StoreOf<TaskListFeature>

    var body: some View {
       VStack {
          List { 
             ForEach(store.tasks) { task in 
                 Text(task.title)  
             }
          }
          Button("Add Task") {
              store.send(.addTask)
          }
       }
    }
}

The last thing is to display the created view, for example in the app’s entry point. All we need is to specify the initial state that the feature would start in and the reducer that would power the feature.

@main
struct MyApp: App {
  var body: some Scene {
    WindowGroup {
      TaskListView(
        store: Store(initialState: TaskListFeature.State()) {
          TaskListFeature()
        }
      )
    }
  }
}

At first glance, this approach has more steps than just using the vanilla SwiftUI, but there are valuable benefits. It gives us a single location to apply state mutations instead of including the logic in UI components or observable objects. We can also test the behavior without doing much additional work.

To test this feature, we would use TestStore, which is created with the same parameters as the Store, but it does some extra work under the hood so that we can be certain how the feature behaves as actions are sent.

@MainActor
func testTaskListFeature() async {
  let store = TestStore(initialState: TaskListFeature.State()) {
    TaskListFeature()
  }
}

After we’ve created the test store, we can replicate user behaviors and check that the right change to the state happened. Let’s simulate the user tapping the add task button.

await store.send(.addTask) {
    $0.tasks = [Task(id: ???, title: "New task")]
}

Now we stumbled upon a situation where we have an uncontrolled dependency in our feature, that cannot be predicted and tested. Luckily, we have an easy way out of this situation. All we need to do is introduce the dependency in our reducer. The Swift-Dependencies library that comes with the TCA framework includes many controllable dependencies out of the box. 

So, the next step would be to go back to the reducer and make a small change. 

@Reducer
struct TaskListFeature {
   @ObservableState
   struct State: Equatable { ..... }
   enum Action { ..... }

   var body: some ReducerOf<Self> { 
       ......
            case addTask: 
               @Dependency(\.uuid) var uuid // adding the controllable dependency
               state.tasks.append(Task(id: uuid(), title: "New task"))
               return .none
      .....
   }
}

Now that our feature uses a controllable dependency, we can override it with the autoincrementing value in our tests when creating the TestStore.

@MainActor
func testTaskListFeature() async {
  let store = TestStore(initialState: TaskListFeature.State()) {
       TaskListFeature()
  } withDependencies: {
       $0.uuid = .incrementing 
  }
}

Now we can predict exactly what UUID would the added tasks have in tests. UUID would incrementally increase every time it gets called, starting with the value 0.

await store.send(.addTask) {
    $0.tasks.append(Task(id: UUID(0), title: "New task"))
}

await store.send(.addTask) {
    $0.tasks.append(Task(id: UUID(1), title: "New task"))
}

Benefits of the Composable Architecture

As we can see from the example above, TCA offers a great state management system that simplifies tracking and propagating the component state. It also simplifies handling asynchronous operations in side effects, providing a clear, testable method. 

Speaking of testing, TCA library unlocks the effortless writing of isolated tests as well as integration tests for features composed of many parts! Finally, TCA library provides a lot of tools and utilities for implementing the Composable Architecture in SwiftUI projects, reducing the amount of boilerplate code required. 

Let’s look at some of the benefits in greater detail. 

1. Compiler-enforced architecture

You may wonder why choose TCA over VIPER or other popular architectures. VIPER also relies on a structured approach, defines components and enforces design patterns to separate concerns effectively. However, it lacks the enforcement that TCA brings to the table. 

TCA not only provides a clear structure for managing state and separation of concerns, but its magic lies in its regulation by the compiler. It uses the power of Swift's typing system to ensure that shortcuts and deviations from best practices are much harder to achieve. 

The compiler becomes your vigilant guardian, safeguarding your code quality and preventing potential pitfalls that can degrade your code in the long run.

2. Synergy between SwiftUI and the Composable Architecture

Both SwiftUI and TCA share a similar declarative paradigm. In TCA, you describe how the state and its transitions look, and the framework will take care of the rest. SwiftUI and TCA provide great separation of concerns because SwiftUI is in charge of rendering the UI while TCA handles states and their transitions in the application.

3. Easy testing

When it comes to testing, it has never been easier to test your application. Even if you’ve never written a test, TCA guides you to write your features to be testable by default. A few additional steps in building your dependencies will let you mock them easily, resulting in predictable external services.

Challenges when migrating your codebase to TCA

Migrating code to the Composable Architecture (TCA) can be a significant undertaking, but it can also lead to more maintainable and scalable code in the long run. While nominally TCA offers integration with UIKit and SwiftUI, we faced some challenges when we migrated a project to TCA. 

1. Steep learning curve

TCA will require some learning and research on your side. You will need an elementary understanding of reducer, state, action, store and side effects. Working with the Async/Await framework in Swift is another requirement. In time, you will learn to access everything using the Store. 

Point-Free offers very good documentation and tutorials to help you in this learning phase. Additionally, their GitHub contains many examples and case studies to explore. It’s useful to check out TCA wrappers that integrate well with SwiftUI and examples of their usage. But even with that, you’ll need to dig deeper into documentation to find more specific wrappers.

2. Dependencies adjustments

To manage the dependencies while using TCA, you would probably need to make some adjustments to your current dependencies. That would introduce some boilerplate since you would need to wrap all the dependencies into protocol witnesses and expose them using the DependencyKey protocol (an approach similar to SwiftUI’s EnvironmentKey protocol). However, a lot of commonly used dependencies are already integrated into TCA and are easy to set up and use out of the box.

3. Harder debugging

First, I must admit that TCA has very descriptive error messages, and it’s clear that maximum effort has been given to help debugging. But subtle bugs can still go under the radar and appear during runtime. Additionally, when it comes to more complex reducers used in the larger views, basic error messages may appear in the body line of the view – and these are not descriptive at all. One of the most common errors that I encountered is “The compiler is unable to type-check this expression in reasonable time.” It usually pops up because of a typo or simple syntax error that would be clearly marked when using vanilla SwiftUI.

4. Struggle with autocomplete

When using the current TCA (1.9.0.) and Xcode (1.5.3) versions, there is a high possibility that the autocomplete would not work as expected. Sometimes, it wouldn’t suggest anything at all, and sometimes it would display completely wrong suggestions. For someone who is in the early learning stage, it could introduce a real pain in overcoming the TCA-related syntax.

Conclusion

It is vital to explore new architectural patterns that not only keep up with the demands of modern applications but also offer robust solutions to common challenges. The Composable Architecture (TCA) emerges as a noteworthy contender in this arena. It's more than just an architecture: it's a design philosophy that encourages the decomposition of complex systems into manageable, reusable components.

TCA embodies the spirit of Swift, as it leverages the language's strengths to create structured, maintainable, and testable applications. Its declarative style aligns seamlessly with SwiftUI, making it an ideal choice for building simple or complex screens. TCA enhances code organization and testability by providing a clear separation of concerns, empowering developers to create scalable applications.

Using TCA in your projects may present a few challenges, from debugging subtleties to the steep learning curve due to its unique concepts. It also calls for a shift in how you manage dependencies in your project. 

And yet, the benefits far outweigh the initial hurdles. With TCA, you gain a powerful architectural pattern that promotes modularization, enhances code maintainability, simplifies state management, and encourages testing. Plus, the TCA library streamlines the implementation of this architecture, reducing boilerplate and offering a variety of utilities for an efficient development process. 

In my experience, migrating a production code to the Composable Architecture was a bit challenging at first, but after some time, it became like writing pure vanilla Swift code. The latest versions of TCA removed a lot of boilerplate code, significantly reducing the need to change the original features. From my point of view, the most important thing in the code migration process is to start with small features and follow the exhaustive documentation.

As iOS development continues to evolve, Composable Architecture offers a fresh perspective on building applications. If you have any questions or thoughts, don’t hesitate to contact ios@undabot.com or our social media pages.
 

Similar blog posts

Get in touch