October 25, 2024
Published by Jake Gordon
The Composable Architecture (TCA) is a design pattern and architectural framework that has come into vogue as a paradigm and tool with which to structure complex iOS codebases, particularly those built with Apple’s modern declarative SwiftUI framework. Recent months have seen BrightHR’s iOS Guild make concerted efforts to refactor the main BrightHR and Blip apps, migrating away from traditional patterns such as MVVM and VIPER in favour of TCA. But has the approach paid off?
Since joining BrightHR as a developer with basic experience of SwiftUI and none of TCA, I’ve continued my colleagues’ work by upgrading my own area of the BrightHR app to the latest TCA APIs. The journey has found me oscillating between feelings of awe at the power of Composable Architecture and despair at its many idiosyncrasies. This article provides a perspective on TCA as it applies to our codebase and the challenges involved in working with it. We will review TCA as a design pattern, the scope of the upgrades, and the challenges experienced along the way. Finally, we will discuss whether iOS’s shiny new toy is a must-have solution in any developer’s arsenal, or whether it is all style and no substance.
Today’s mobile developers have a range of design patterns to choose from, each designed to give optimal structure to their code. Every pattern is different, but their tenets tend to converge around one key principle - Separation of Concerns. This involves modularisation - e.g. code related to the user interface (UI) is separate from business logic, which is in turn separate from dependencies such as network calls or database operations. This makes a codebase easier to scale, test, and maintain, whilst improving its legibility, organisation, stability, and integrity.
TCA (which draws inspiration from the Redux pattern) is no different in this regard, and yet stands out as particularly complex compared to other design patterns for one key reason: unlike other patterns, which amount to sets of best practices regarding how to organise code, TCA is also a framework with its own APIs, syntaxes, and libraries that must be mastered. TCA is maintained by Point-Free, who regularly update the framework and publish migration guides to complement their user manuals.
The Composable Architecture is based around the following components (for more detail visit: https://www.pointfree.co/collections/composable-architecture):
For those familiar with MVVM, the TCA Store is conceptually similar to a view model. It serves as a conduit between the UI, dependencies, and data models, housing the other components and sending Actions to the Reducer. The Store differs from a view model in that it is more rigid in scope; State variables ought to be defined in the Store, for example, and are usually not modified from within the View. When integrated with SwiftUI the Store is reactive, and data flow is unidirectional. Unlike a view model, the TCA Store represents a feature rather than a particular view, enhancing its reusability. Below is an example of a simple Store for a feature that displays a list of documents. We see that the Store houses the State, Reducer, Actions, and Effects:
@Reducer
struct DocumentStore: Reducer {
@ObservableState
struct State: Equatable {
var documentsListState: DocumentsList = .loading
var allDocuments: [Document] = []
var selectedDocumentID: String = ""
var documentName: String = ""
var showPDFViewer: Bool = false
}
enum DocumentsList: Equatable {
case loading
case empty
case loaded
case error
}
enum Action: Equatable {
case getAllDocuments
case getAllDocumentsResponse(Result<[Document], BHRServerError>)
}
var body: some ReducerOf<Self> {
BindingReducer()
Reduce { state, action in
switch action {
case .getAllDocuments:
state.documentsListState = .loading
return .run { send in
let items = try await self.documentsClient.getAllDocuments()
await send(.getAllDocumentsResponse(.success(items ?? [])))
} catch: { error, send in
await send(.getAllDocumentsResponse(.failure(error as? BHRServerError ?? BHRServerError.generic)))
}
case let .getAllDocumentsResponse(.success(items)):
state.allDocuments = items
state.documentsListState = state.documents.isEmpty ? .empty : .loaded
return .none
case let .getAllDocumentsResponse(.failure(error)):
state.documentsListState = .error
return .none
}
}
}
}
Where the magic happens. Referenced in the Store, the TCA Reducer handles Actions delivered from the View via the Store, either by manipulating State, or by sending Effects.
var body: some ReducerOf<Self> {
BindingReducer()
Reduce { state, action in
switch action {
case .getAllDocuments:
state.documentsListState = .loading
return .run { send in
let items = try await self.documentsClient.getAllDocuments()
await send(.getAllDocumentsResponse(.success(items ?? [])))
} catch: { error, send in
await send(.getAllDocumentsResponse(.failure(error as? BHRServerError ?? BHRServerError.generic)))
}
case let .getAllDocumentsResponse(.success(items)):
state.allDocuments = items
state.documentsListState = state.documents.isEmpty ? .empty : .loaded
return .none
case let .getAllDocumentsResponse(.failure(error)):
state.documentsListState = .error
return .none
}
}
}
Like the Reducer, the TCA State is housed in the Store. Here, we keep track of state variables.
@ObservableState
struct State: Equatable {
var documentsListState: DocumentsList = .loading
var allDocuments: [Document] = []
var selectedDocumentID: String = ""
var documentName: String = ""
var showPDFViewer: Bool = false
}
In TCA, Actions are defined as enum cases. They may accept parameters and are usually triggered by user interactions in the View and handled in the Reducer.
enum Action: Equatable {
case getAllDocuments
case getAllDocumentsResponse(Result<[Document], BHRServerError>)
}
Effects are triggered by Actions in the Reducer. They are asynchronous and usually represent events handled by services and dependencies.
return .run { send in
let items = try await self.documentsClient.getAllDocuments()
await send(.getAllDocumentsResponse(.success(items ?? [])))
} catch: { error, send in
await send(.getAllDocumentsResponse(.failure(error as? BHRServerError ?? BHRServerError.generic)))
}
Whilst not a TCA component, the View is where it all comes together. Once your TCA Store has been configured, it’s as easy as instantiating it in your View and starting to send actions.
struct DocumentsView: View {
@Perception.Bindable var store: StoreOf<DocumentStore>
init(store: StoreOf<DocumentStore>) {
self.store = store
}
var body: some View {
WithPerceptionTracking {
ZStack {
switch store.documentsListState {
case .loading:
LoadingStackView()
case .empty:
centeredContentView {
MUI.EmptyView(image: "empty-states-llustrations", description: .constant("no_payslips_to_view".localized), retryButtonState: .constant(.disabled))
}
case .loaded:
VStack(spacing: -10) {
VStack {
ScrollView {
VStack(spacing: -10) {
ForEach(store.documents, id: \.providerPaymentId) { document in
DocumentListFileCell(document: document) {
store.selectedDocumentID = document.providerPaymentId
store.fileName = document.documentString()
}
}
}
}
}
}
case .error:
centeredContentView {
MUI.EmptyView.error(retryButtonState: .constant(.enabled)) {
store.send(.getAllPayslips)
}
}
}
}
.header(navigationTitle: "my_documents".localized,
dismissButtonIcon: .chevronLeft, trailingButtonLeft: .some(.init(iconName: "", onButtonTapped: {
dismiss()
})))
.onAppear {
store.send(.getAllPayslips)
}
.fullScreenCover(isPresented: $store.showPDFViewer, content: {
if let filePath = store.filePath {
FileView(
fileName: store.fileName,
filePath: filePath.path(),
onDismiss: {
store.showPDFViewer = false
}
)
}
})
}
}
}
So far, so simple. But TCA can grow more complicated in line with the codebase. Take navigation for example: TCA provides its own APIs to handle destinations using a method called scoping, which allows us to observe Stores from multiple Views. This can lead to Views sharing state variables that were not designed for them (or not using them thereby violating the Interface Segregation Principle). Navigation can be tree-based, or stack-based, or enum-driven depending on the need, all of which require knowledge of specialised TCA APIs, making a simple and common behaviour entirely not that.
Efforts are ongoing to refactor the main BrightHR app (Blip too) to TCA however this work has been limited by the complexity of existing features and areas of the codebase being tightly integrated with other design patterns or deprecated frameworks such as UIKit. However, new features of the app are written in TCA, and certain key areas have been tackled at great effort.
One such area is Documents. Within the purview of my team, this feature has already undergone impressive refactoring work to TCA prior to my joining. Documents offers users a wide range of functionality including viewing, uploading, and downloading documents. Underlying these features are several TCA Stores, including ones for the Uploader, the Downloader, Document Root View, and one large Store in particular that is scoped to many smaller Views called DocumentListLogic. This functions as the main ‘brain’ of Documents.
Over the last few months I have been upgrading Documents to the latest TCA APIs. This has largely revolved around updates made by Point-Free that allow us to create observable Stores. Previously, in order to access and manipulate State reactively within a TCA View we would have to filter our Store through a ‘ViewStore’ in the form of a wrapper around our SwiftUI View. Now, thanks to the new Perception Tracked Store, we can remove the ViewStore altogether. However, given that Perception Tracked Store is only compatible with iOS 17 onwards and we continue to support iOS 16, Point-Free has provided another wrapper called WithPerceptionTracking to backport the Store’s observability. Other changes have included:
The syntax of TCA APIs is obtuse, constantly changing, and conceals substantial amounts of code under the hood. This makes learning and implementing it challenging. It also makes diagnosing TCA issues a frustrating experience, as it forces the developer to take on a much more proactive role in finding and resolving errors, especially given that the Xcode (our native iOS development software) compiler often struggles to diagnose syntax issues in hefty SwiftUI Views. Complicated Reducers can also cause issues with indexing and code completion, forcing us to pass them explicit types in order to pinpoint issues more smoothly.
As mentioned, the new Perceptible Store allows us to observe State directly from within the View, provided we wrap parts of the View in WithPerceptionTracking {}. While a welcome and much-needed addition for those of us supporting older iOS versions, in practice it has been challenging due to the lack of clarity over which parts actually need tracking. According to Point-Free: “There is one small change you have to make to the view, and that is wrap it in WithPerceptionTracking… … it’s just a small bit of additional code in your view, and the library will helpfully tell you when you have forgotten it.” Awesome! Well, not quite. Actually, anything containing an escaping closure needs wrapping, so you can’t just chuck a wrapper round your View and wash your hands of it. The danger of not tracking observation is significant; if certain properties are not responding reactively to user interaction the app may not function as it should. And yet while these errors of perception tracking are caught by the compiler at runtime, they do not restrict the app from building, and worse, do not give any indication of which properties are not being tracked, or where.
Such a scenario occurred on the Blip app – production issues were reported due to State not being tracked to the surprise of my colleague, who had wrapped all Views in PerceptionTracking. Upon closer investigation, escaping closures were found to have been overlooked, something which could have been avoided with more precise documentation.
The upshot is an inordinate amount of time spent pinpointing the places where perception isn’t tracked, often by trial and error by commenting out the entire view, then adding it back in piecemeal and running the app until the error is found, a laborious and time-intensive process. In addition, the source of some of these errors may not always be in the code file that throws the warning, but a connected file or View that has been scoped down one or more levels.
Proponents of TCA boast of its high modularity, which is accurate from a certain point of view. It is indeed easy to swap in and out dependencies or third-party libraries for features using Composable Architecture, given that only the Store and Actions are referenced in the View. It’s also easy to replace Stores with others, and to test. However, when it comes to the architecture itself, intra-TCA, it becomes challenging.
For example, this upgrade was initially only intended for DocumentListView, which is the main parent view for the list of documents and folders. However, given that DocumentListView (which uses DocumentListLogic Store) scopes its Store to every single view that it connects with, views which in turn scope either this Store or combinations of the Store’s Actions with the States of other Stores, in practice it is impossible to upgrade the main DocumentListView without doing the same for everything it interacts with. This was a key factor in the length of the work, as ideally it would have been approached as a series of small incremental pull requests, but with TCA this is not possible. It’s like pulling up the floorboards in your bedroom, then realising they connect to the hallway, and then the living room and then suddenly you’re doing every room in the house and it’s like a huge spider-web that you can’t keep track of.
Consequently, the upgrades had to be repeatedly dropped in favour of feature work. This made the approach disjointed, and due to carrying it all out on one PR, caused merge conflicts that took time to resolve due to the branch growing out of date with the main repository.
Relatively minor complaint, but it’s frustrating that TCA shares a name with Composable (the term for views in Jetpack Compose, Android’s facsimile of SwiftUI), meaning that whenever I Googled TCA issues I’d have to sift through loads of irrelevant Android threads in order to get to what I needed.
More of an observation than a challenge. When do we scope our Store into others? When do we create a new Store altogether? The answer seems to come down to personal preference, although I would uphold the case for Documents and in general that more, smaller Stores would serve better, reducing the need for scoping and thus intra-dependency within the area.
As mentioned, navigation can be particularly tricky with TCA, and ultimately much of the upgrade work in this area had to be put off for the sake of expediency. Navigation APIs in DocumentListView have not been upgraded in a while, and must be worked on piecemeal, following each migration guide released between now and when they were current. Again, do things really need to be this difficult? Navigation is a standard part of the SwiftUI framework and one of the most common features made use of by apps. It should be simple but with TCA it is anything but.
Given its numerous pain-points – why did we switch to TCA in the first place? Well, in a word – testing.
It’s hard to overstate the importance of testing in mobile development. We use various methods of testing in iOS from integration, to UI, to unit in order to guarantee not only that our code works the way we expect it to, but also that it provides a safeguard against future work introducing unexpected behaviours.
TCA makes testing incredibly easy, both by virtue of its modular design, and specific testing frameworks. Each TCA feature is its own self-contained component, which makes it simple to test in isolation. Dependencies, which are notoriously tricky to test, can be swapped out easily with TCA’s dependency injection approach. Actions make it easy to test any conceivable scenario or edge case that might occur within our View/Feature.
Composable Architecture has many other benefits, but its security and reliability via testing are what make it such a powerful choice for the project architecture. It’s not hard to see how a steep learning curve and occasional integration headaches are small prices to pay for a huge increase in the stability of our codebase, giving us confidence in a robust user experience and helping maintain our 99%+ crash-free rate.
Documents is an important part of our app, and there is a great deal of work that needs doing, from issues in functionality, to performance issues, to updating the UI with our custom Bright components. Some might say that spending several months upgrading an architecture that upon successful completion brings no discernible improvement in UX is not time best spent.
So, is it worth it then? Could we not use a more tried and true design pattern in our codebase such as MVVM, that does not require the use of complicated APIs from what amounts essentially to a side project that significantly impact development time and need upgrading every other month?
Let’s not be too harsh on TCA. As we’ve seen, the positives are compelling, and adopting it in our codebase was a decision not taken lightly. Testing has become a breeze with TCA. Building new features is straightforward, and the margin for error is a lot smaller when each of us writes in a consistent style enforced upon us by the TCA framework.
Every decision made in software development requires trade-offs. IMHO, TCA is a powerful tool and the decision to use it in our codebase was a bold one that is already paying dividends. Nevertheless, TCA must be used appropriately and diligently in order to take full advantage of its features. That said, I’d offer the following thoughts to any developers considering following in our steps:
i. Instead of refactoring large, messy features to TCA, consider re-building them from scratch, as TCA is also its own way of thinking, and can get in the way of itself when shoehorned into existing code.
ii. Before adopting TCA, check the rate at which updates to the framework are rolled out and ask yourselves whether you have the stamina to be regularly taking on lengthy refactoring work, or whether it might be best to wait for the framework to further mature.
iii. TCA requires a greater degree of collaboration and knowledge-sharing, if only to ensure team members are kept apprised of what their colleagues are doing in their own areas so that there is always more than one person in the team with a good handle on what is happening in the case of developer turnover.
Overall, if best practices are followed within a strong team, TCA can be an awesome tool for iOS developers everywhere, and a game-changing one in the right hands.
Registered Office: Bright HR Limited, The Peninsula, Victoria Place, Manchester, M4 4FB. Registered in England and Wales No: 9283467. Tel: 0844 892 3928. I Copyright © 2024 BrightHR