Migrating to The Composable Architecture (TCA) 1.0
Sharing my learnings and my code structure after migrating my app to the vastly modernized APIs of TCA 1.0.
Intro & Results
I just migrated my app RemafoX which was built upon The Composable Architecture (TCA) version 0.35.0
to the new 1.0
style of APIs. While the time between the release of 0.35.0
and the current beta of 1.0
spans less than a year, it is important to note that no less than 27 feature releases with sometimes significant changes were made in that short period of time. The Point-Free team really is working in full swing on improving app development for Swift developers, and TCA is the culmination of most of their work. And nearly every topic they discuss in their great advanced Swift video series has some effect on TCA. While they managed to keep all changes pretty much source-compatible up until the current version 0.52.0
, the 1.0
release is going to get rid of a lot of older style APIs that were marked as deprecated for some time already.
Because I find it very inefficient to constantly question every decision I made regarding the architecture or conventions of my apps code, I didn't invest much time in catching up with all the improvements they made to TCA in recent months, although I did keep one eye open to ensure I'm aware of the general direction. But the nearing release of the milestone version 1.0
of the library and the fact that I'm planning to work on some bigger features for RemafoX next make it a good time to reconsider and learn about how to best structure my apps going forward.
Thankfully, the general concept of TCA has not changed at all. But the APIs to describe how features are connected, how navigation should work, how asynchronous work is declared, and even how dependencies are passed along have received significant changes since, all for the better, using the latest Swift features. So there was a lot to figure out and migrate for me and I tackled all of these areas of change at once, but to keep things manageable, I applied the changes module for module to all of the 33 UI features of my app modularized using SwiftPM.
Here are the main takeaways of the migration process up-front:
- It took me a full work week (~5 days) to complete the migration.
- My code base has shrunk by 2,500 lines of code, which is a ~7% reduction.
- A few navigation bugs, threading issues, and SwiftUI glitches are fixed now.
- My code is much easier to understand, navigate, and reason about.
As for the testing story of my app, all of my tests are actually still passing and I did not have to make any changes to the test code. The reason for that is that I currently only have tests for non-UI features like parsing data, searching for files, or making changes to Strings files – and quite extensive tests in some parts here. But when I considered also writing tests for my UI, I was already months behind my initial timeline of releasing the app and additionally, TCA was still a few weeks away from supporting non-exhaustive testing. So I decided against adding UI tests as I wasn't really happy with how often one had to make changes to tests just because of some refactoring on the UI layer that didn't really change the general behavior but required what felt like a rewrite of the related tests because of their exhaustive nature. But with non-exhaustive testing available now, I'm planning on writing UI tests for my app step by step, beginning with the most important ones: My most business-logic-heavy feature, and all my Onboarding features. I might write about this in a future article.
But for now, let's focus on how I tackled the migration of my app's code base.
Before the Migration (Case Example)
I think the best way to explain what changes were necessary and also what other changes I did to further streamline things is to show some real-world code. So in the following I will show you how the actual code of my app's simplest feature looked before the migration and how I evolved it to the new TCA 1.0
style.
The feature is named AppInfo
in my code base and looks like this in the app:
Before the migration, the features code was split up to 7 different files:
The AppInfoState
and AppInfoAction
define the data and interactions possible:
Note that I had always defined typealiases for related parts of the feature I might reference somewhere within the types for convenience, even if I had not actually used them. Also, I had an extra action set<name of child>(isPresented:)
whenever I had a child view that I wanted to present via a sheet sometime later. If you're wondering what those imports of AppFoundation
and AppUI
are, I've explained them in this article. They help reduce the number of imports in my app.
Next, here's what AppInfoView
file looks like:
Note that for presenting a sheet I have to write no less than 11 lines of code and send the action setErrorHandling(isPresented:)
back into the system manually. Also, experienced developers might notice that I'm actually using global dependencies in my view code, such as with Plan.loadCurrent()
, which doesn't make my UI code very testable. But I will introduce them as proper dependencies once I start writing tests for UI, so let's ignore these for now.
The last missing piece of the puzzle for a feature in TCA is the AppInfoReducer
:
import AppFoundation
import AppUI
public let appInfoReducer = AnyReducer.combine(
errorHandlingReducer
.optional()
.pullback(
state: \AppInfoState.errorHandlingState,
action: /AppInfoAction.errorHandling(action:),
environment: { $0 }
),
AnyReducer<AppInfoState, AppInfoAction, AppEnv> { state, action, env in
let actionHandler = AppInfoActionHandler(env: env)
switch action {
case .onAppear, .onDisappear:
return .none // for analytics only
case .selectedAppIconChanged:
return actionHandler.selectedAppIconChanged(state: &state)
case .copyEnvironmentInfoPressed:
return actionHandler.copyEnvironmentInfoPressed(state: &state)
case .binding:
return .none // assignment handled by `.binding()` below
case .errorOccurred, .setErrorHandling, .errorHandling:
return actionHandler.handleErrorAction(state: &state, action: action)
}
}
.binding()
.recordAnalyticsEvents(eventType: AppInfoEvent.self) { state, action, env in
switch action {
case .onAppear:
return .init(event: .onAppear)
case .onDisappear:
return .init(event: .onDisappear)
case .copyEnvironmentInfoPressed:
return .init(event: .copyEnvironmentInfoPressed)
case .errorOccurred(let error):
return .init(event: .errorOccurred, attributes: ["errorCode": error.errorCode])
case .binding, .setErrorHandling, .errorHandling, .selectedAppIconChanged:
return nil
}
}
)
First, note how the appInfoReducer
is defined on a global level, which feels wrong already. Next, 7 lines are required to connect the child feature ErrorHandling
to this feature. And you'll notice that I have introduced yet another type named AppInfoActionHandler
which seems to hold the actual logic of the reducer. The reason for that is that some of my reducer logic is quite long and if I kept all logic inside the switch-case
, I'd have a lot of cases with a lot of code inside. But Xcode doesn't provide any features to help find and navigate between switch
cases. So I've extracted that logic to functions in another type. Lastly, you will notice that I have defined an extension to the AnyReducer
type itself for analytics purposes:
All this does is record an event in my Analytics engine powered by TelemetryDeck for the actions that I want to record. I find this very useful as a reminder to always consider for each new action I add to the AppInfoAction
enum if I may want to analyze the new event in a fully anonymized way. To make this work properly, I also have to define another type for each feature, here AppInfoEvent
:
This enum defines all events I want to collect, and the idComponents
property helps auto-create a String when passing an event name to my Analytics provider. The AnalyticsEvent
protocol is a bit off-topic, but if you're interested it's just this:
You might have also spotted an AppEnv
type that I use for the environment. This is actually a shared type which I reuse wherever I just need a basic environment type with a mainQueue
and which is passed around all over my application:
import CombineSchedulers
import Defaults
import Foundation
public struct AppEnv {
public let mainQueue: AnySchedulerOf<DispatchQueue>
public init(mainQueue: AnySchedulerOf<DispatchQueue>) {
self.mainQueue = mainQueue
}
}
#if DEBUG
extension AppEnv {
public static var mocked: AppEnv {
.init(mainQueue: DispatchQueue.main.eraseToAnyScheduler())
}
}
#endif
Now, the last of the 7 files is AppInfoError
and that type is actually empty for this very simple feature. But I will explain its purpose in a later article where I will cover my error-handling approach in great detail. All you need to know for this article is that when something unexpected happens, I want to show a sheet with some helpful information right in the context of a feature.
Point-Free tends to keep all their types in a single file, which might work for a small feature like AppInfo
. But a typical feature of mine takes about 500 to 1,500 lines of code with all types combined. I typically tend to keep my files small with a soft cap of 400 lines and a hard cap of 1,000 lines (see SwiftLint rule defaults). With 314 lines even this very simple feature already would come close to the soft cap and some of my features might even get above the hard cap. So keeping it all in one file is a no-go for me. Thus I decided to put each type in its own file instead. But I also never was 100% happy with that, as things seem very all over the place. In the best case, related code would still be together but the feature would still be evenly distributed to fewer files than 7. So, let's see how things look after the migration.
After the Migration (Case Example)
In the TCA 1.0
beta, Point-Free introduced the concept of creating a special struct
that serves as the scope or namespace for a feature and they put helping types like State
and Action
as subtypes into that namespace type. They gave this namespace the name Feature
and made it conform to Reducer
, which looks something like this:
struct Feature: Reducer {
struct State: Equatable { … }
enum Action: Equatable { … }
func reduce(into state: inout State, action: Action) -> Effect<Action> { … }
}
To me, this structure is confusing though for two reasons:
- Naming the namespace with the suffix
Feature
while making it conform toReducer
seems off to me. Anywhere areducer
parameter need to be passed, we'd pass aFeature
which seems confusing. The namespace then should be namedReducer
in the first place, but then, we'd have subtypes accessed throughReducer.State
andReducer.Action
, which also isn't correct. - Fully embracing the idea of a feature namespace, I'd expect a type
Reducer
inside theFeature
type for consistency.
Instead, I opted for actually using the Feature
as a namespace and also putting a Reducer
subtype in it that conforms to Reducer
. Well, that would result in struct Reducer: Reducer
which is a name clash, let's solve that with a typealias:
import ComposableArchitecture
public typealias FeatureReducer: Reducer
Now we could define a Reducer: FeatureReducer
subtype in our Feature
namespace. And while we're at it, I actually tend to forget to write a public initializer for my reducers (which is required since the app is modularized), so let's define a new public protocol instead which requires a public initializer:
import ComposableArchitecture
public protocol FeatureReducer: Reducer {
init()
}
Actually, there are more things I tend to forget about other TCA feature types. Let's make it all a clear requirement by implementing protocols like FeatureReducer
for all kinds of subtypes within a Feature
:
Note that I require FeatureState
and FeatureAction
to be Equatable
, which is always a good idea in TCA to make them testable and all my state & action types already conform to it anyways. Additionally, I defined a FeatureView
accordingly, plus the two extra types I need for my Analytics and Error Handling needs. Note that I also decided to instead of adding the State
suffix to all child features as I did with errorHandlingState
in the feature previously, I decided to go for the child
prefix instead as in childErrorHandling
which makes finding child features easier while scanning the attributes from top to bottom.
With these protocols in place, we can now even teach the compiler what a "feature" actually is by defining another protocol that requires all the subtypes:
I also defined a typealias for defining the store
in our views similar to the new StoreOf
typealias created by Point-Free, but specific to a Feature
.
Alright, with this up-front work, let's see what the migrated Feature
looks like:
Note that I defined an enum
instead of a struct
for the Feature
to signify that this merely represents a namespace. Next, all of the subtypes conform to exactly what their name is with Feature
added as a prefix, e.g. State: FeatureState
. This makes it really easy to remember what to conform to and the code more consistent.
Here's the State
body I left out from the code sample above for a better overview:
public struct State: FeatureState {
@BindingState
var showEnvInfoCopiedToClipboard: Bool = false
var selectedAppIcon: AppIcon
@PresentationState
public var childErrorHandling: ErrorHandlingFeature.State?
public init() {
self.selectedAppIcon = Defaults[.selectedAppIcon]
}
}
This looks pretty similar to the original AppInfoState
, but this time the child is renamed from errorHandlingFeature
to childErrorHandling
. Also because I also migrated the child feature itself, the type changed from ErrorHandlingState?
to ErorHandlingFeature.State?
. Also, I added the @PresentationState
attribute for the new navigation style in TCA 1.0
that supports dismissal from within the child using @Dependency(\.dismiss)
and calling self.dismiss()
in the Reducer
.
Next, let's take a look at our Action
subtype:
public enum Action: FeatureAction, BindableAction {
case onAppear
case onDisappear
case selectedAppIconChanged
case copyEnvironmentInfoPressed
case binding(BindingAction<State>)
case errorOccurred(Error)
case childErrorHandling(PresentationAction<ErrorHandlingFeature.Action>)
}
This is also pretty much a copy of the original AppInfoAction
, but note that the child action has now a different type. It changed from HelpfulErrorAction
to PresentationAction<HelpfulErrorFeature.Action>
, which is a wrapper that puts all child actions into a case named .presented
– the other case .dismiss
reports back that the child was dismissed in case the parent needs to react to that. Thanks to PresentationAction
, I could completely get rid of the action setErrorHandling(isPresented:)
as this is now encapsulated in TCA-provided types.
Let's now take a look at what our View
looks like:
public struct View: FeatureView {
let store: FeatureStore<AppInfoFeature>
public init(store: FeatureStore<AppInfoFeature>) {
self.store = store
}
}
As you can see, I'm using the FeatureStore
typealias instead of the old style Store<State, Action>
or the TCA 1.0
style StoreOf<Feature>
. But where's everything else that defines a SwiftUI View
like the body
property? Well, the implementation of a view typically is one of the longest parts of a feature, so while I opted to keep the structural parts of all subtypes in one place, conformances to protocols that require a lot of code I extracted to extension files.
The implementation of the View
is in a separate file as an extension:
The implementation of the body
property is pretty much the same as before. But note that the 11 lines .sheet
modifier has shrunk to just 3 lines. This is thanks to the new navigation tools using @PresentationState
and PresentationAction
. Another change happened to the static let store
inside the PreviewProvider
: There's no environment parameter to pass to the Store anymore!
Let's take a look at the Reducer
subtype to learn why this is:
public struct Reducer: FeatureReducer {
@Dependency(\.mainQueue)
var mainQueue
@Dependency(\.continuousClock)
var clock
public init() {}
}
Note the usage of the @Dependency
attribute. It might remind you of the @Environment
attribute in SwiftUI, and it actually works exactly the same. This new attribute is why there's no Environment
type needed anymore in TCA 1.0
. Instead, all dependencies are declared using the @Dependency
attribute. This allows me to entirely get rid of the AppEnv
type I had passed around before.
Yet again, you might be missing the actual implementation of the Reducer
protocol. Well, the implementation of the protocol is the second portion of code in a feature that can get pretty long, so I also opted to extract that to its own file:
Note first the entirely different structure. No global reducer
variables are needed anymore. Instead, a body
property is implemented, very much like with the View
protocol in SwiftUI. And the analogy doesn't end there, the structure is also very SwiftUI-like with a mere list of different reducers that together build the AppInfoFeature.Reducer
, including one called BindingReducer()
which replaces .binding()
. Also note that the 7 lines of code connecting the child feature have shrunk down to just 3 lines using the new .ifLet
API. Additionally, instead of having to define a custom ActionHandler
type where I put the implementation of the logic to react upon actions, because we are now in a type and not a global level, I could easily move those functions into the Reducer
type itself. Also, the implementation of copyEnvironmentInfoPressed
is using the new async
style APIs. Previously, it was implemented using the less readable Combine
style:
Lastly, due to the new SwiftUI-like function builder style, I had to change my AnyReducer
extension function recordAnalyticsEvents
to simply being a Reducer
that stores the execution logic as a property like so:
The body of the analytics helper in the Reducer above didn't change at all, I just copied it over from the previous helper function into this new reducers initializer.
And that's all, the entire AppInfo
feature is migrated over to TCA 1.0
. The overall file structure now looks like this, with just 3 files instead of 7:
Note that I'm using *
as a separator for signaling that the file contains the main portion of a subtype. Naturally, we could use .
as a separator making the name read like AppInfoFeature.Reducer.swift
. But because .R
from .Reducer
is sorted above .s
from .swift
, this would result in the subtypes files appearing above the main feature file AppInfoFeature.swift
, so I opted for a separator that looks similar to a dot but has lower precedence than .
which lead to *
.
The SwiftLint rule file_name
which I opted in to showed me a warning with this naming style. But I could easily adjust that by adding this to the config file:
file_name:
nested_type_separator: '*'
Conclusion
Migrating my medium-sized app to the new TCA 1.0
style of APIs was a lot of work, but most of it was setting up file structures, doing search & replace, and moving existing code to other places. And I invested quite some of my time into figuring out a good structure that I liked. I think if I had to do it again for another app with my learnings, I'd probably be done in 2-3 days rather than 5.
Only in a few places I had to actually adjust code, mostly when migrating Combine-style effect code in my reducers to async-await style code. But thanks to great documentation and warnings, it was always pretty clear what to do. For everyone doing a similar migration, here are the 3 links I found most useful:
Also, if there's one episode that gives a good overview of the advancements in TCA 1.0
, it's the first ~35 minutes of episode #222 which kicks off Composable Navigation. Watch it to quickly get an idea of how things changed in the past year.
A drag & drop translator for String Catalog files – it's really easy.
Get it now to machine-translate your app to up to 150 languages!