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.

Migrating to The Composable Architecture (TCA) 1.0
Photo by Mathew Schwartz / Unsplash

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:

  1. It took me a full work week (~5 days) to complete the migration.
  2. My code base has shrunk by 2,500 lines of code, which is a ~7% reduction.
  3. A few navigation bugs, threading issues, and SwiftUI glitches are fixed now.
  4. 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:

The "About RemafoX" screen (Cmd+I).

Before the migration, the features code was split up to 7 different files:

Feature parts: Action, ActionHandler, Error, Event, Reducer, State, and View.

The AppInfoState and AppInfoAction define the data and interactions possible:

import AppFoundation
import AppUI

public struct AppInfoState: Equatable {
   public typealias Action = AppInfoAction
   public typealias Error = AppInfoError

   @BindingState
   var showEnvInfoCopiedToClipboard: Bool = false
   var selectedAppIcon: AppIcon

   var errorHandlingState: ErrorHandlingState?

   public init() {
      self.selectedAppIcon = Defaults[.selectedAppIcon]
   }
}

AppInfoState.swift

import AppFoundation
import AppUI

public enum AppInfoAction: Equatable, BindableAction {
   public typealias State = AppInfoState
   public typealias Error = AppInfoError

   case onAppear
   case onDisappear
   case selectedAppIconChanged
   case copyEnvironmentInfoPressed

   case binding(BindingAction<State>)

   case errorOccurred(error: Error)
   case setErrorHandling(isPresented: Bool)
   case errorHandling(action: ErrorHandlingAction)
}

AppInfoAction.swift

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:

import AppFoundation
import AppUI

public struct AppInfoView: View {
   public typealias State = AppInfoState
   public typealias Action = AppInfoAction

   let store: Store<State, Action>

   public init(store: Store<State, Action>) {
      self.store = store
   }

   public var body: some View {
      WithViewStore(self.store) { viewStore in
         VStack(alignment: .leading, spacing: 20) {
            VStack(alignment: .center, spacing: 10) {
               viewStore.selectedAppIcon.image
                  .resizable()
                  .aspectRatio(contentMode: .fit)
                  .frame(width: 128, height: 128)
                  .onChange(of: Defaults[.selectedAppIcon]) { newValue in
                     viewStore.send(.selectedAppIconChanged)
                  }

               Text(Constants.appDisplayName)
                  .font(.system(size: 33, weight: .light, design: .rounded))

               Text("Copyright © 2022 Cihat Gündüz")
                  .font(.footnote)
                  .foregroundColor(.secondary)
            }
            .frame(maxWidth: .infinity)

            Divider()

            VStack(alignment: .center, spacing: 10) {
               Text("Environment Info")
                  .font(.headline)

               Text("Provide these info when reporting bugs or use Help menu.")
                  .frame(maxWidth: .infinity, alignment: .leading)
                  .font(.subheadline)
                  .padding(.bottom, 5)

               HStack {
                  Text("App Version:").foregroundColor(.secondary)
                  Spacer()
                  Text(Bundle.main.versionInfo)
               }

               HStack {
                  Text("System Version:").foregroundColor(.secondary)
                  Spacer()
                  Text(ProcessInfo.processInfo.operatingSystemVersionString.replacingOccurrences(of: "Version ", with: ""))
               }

               HStack {
                  Text("System CPU:").foregroundColor(.secondary)
                  Spacer()
                  Text(KernelState.getStringValue(for: .cpuBrandString))
               }

               HStack {
                  Text("Tier:").foregroundColor(.secondary)
                  Spacer()
                  Text(Plan.loadCurrent().tier.displayName)
               }

               Button {
                  viewStore.send(.copyEnvironmentInfoPressed)
               } label: {
                  Label("Copy", systemSymbol: .docOnClipboard)
               }
               .padding(.top, 10)
               .popover(isPresented: viewStore.binding(\.$showEnvInfoCopiedToClipboard), arrowEdge: Edge.top) {
                  Text("Copied!").padding(10)
               }
            }
         }
         .frame(width: 320)
         .padding()
         .onAppear { viewStore.send(.onAppear) }
         .onDisappear { viewStore.send(.onDisappear) }
         .sheet(
            isPresented: viewStore.binding(
               get: { $0.errorHandlingState != nil },
               send: Action.setErrorHandling(isPresented:)
            )
         ) {
            IfLetStore(
               self.store.scope(state: \State.errorHandlingState, action: Action.errorHandling(action:)),
               then: ErrorHandlingView.init(store:)
            )
         }
      }
   }
}

#if DEBUG
   struct AppInfoView_Previews: PreviewProvider {
      static let store = Store(
         initialState: .init(),
         reducer: appInfoReducer,
         environment: .mocked
      )

      static var previews: some View {
         AppInfoView(store: self.store)
      }
   }
#endif

AppInfoView.swift

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:

import ComposableArchitecture

extension AnyReducer {
   /// Returns a `Result` where each action coming to the store first attempts to record an analytics event.
   /// In the implementation, switch over `action` and return an ``Analytics.AttributedEvent`` if the action should be recorded, or else return `nil`.
   public func recordAnalyticsEvents<Event: AnalyticsEvent>(
      eventType: Event.Type,
      event toAttributedEvent: @escaping (State, Action, Environment) -> Analytics.AttributedEvent<Event>?
   ) -> Self {
      .init { state, action, env in
         guard let attributedEvent = toAttributedEvent(state, action, env) else { return self.run(&state, action, env) }

         return .concatenate(
            .fireAndForget { Analytics.shared.record(attributedEvent: attributedEvent) },
            self.run(&state, action, env)
         )
      }
   }
}

AnyReducerExt.swift

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:

import AppFoundation

enum AppInfoEvent: String {
   case onAppear
   case onDisappear
   case copyEnvironmentInfoPressed
   case errorOccurred
}

extension AppInfoEvent: AnalyticsEvent {
   var idComponents: [String] {
      ["AppInfo", self.rawValue]
   }
}

AppInfoEvent.swift

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:

import Foundation

public protocol AnalyticsEvent: Identifiable where ID == String {
   var idComponents: [String] { get }
}

extension AnalyticsEvent {
   public var id: String {
      self.idComponents.joined(separator: ".")
   }
}

AnalyticsEvent.swift (part of a helper module named Analytics)

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.


Want to see your ad here? Contact me at ads@fline.dev to get in touch.

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:

  1. Naming the namespace with the suffix Feature while making it conform to Reducer seems off to me. Anywhere a reducer parameter need to be passed, we'd pass a Feature which seems confusing. The namespace then should be named Reducer in the first place, but then, we'd have subtypes accessed through Reducer.State and Reducer.Action, which also isn't correct.
  2. Fully embracing the idea of a feature namespace, I'd expect a type Reducer inside the Feature 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:

import Analytics
import ComposableArchitecture
import ErrorHandling
import SwiftUI

public protocol FeatureState: Equatable {
   var childErrorHandling: ErrorHandlingFeature.State? { get set }
}

public protocol FeatureAction: Equatable {
   associatedtype ErrorType: FeatureError

   static func errorOccurred(_ error: ErrorType) -> Self
   static func childErrorHandling(_ action: PresentationAction<ErrorHandlingFeature.Action>) -> Self
}

public protocol FeatureEvent: AnalyticsEvent {}

public protocol FeatureError: HelpfulError {}

public protocol FeatureReducer: Reducer {
   init()
}

public protocol FeatureView: View {
   associatedtype Action: FeatureAction
}

Excerpt of Feature.swift (part of a helper module)

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:

/// A namespace for a TCA feature with extra requirements for Analytics and Error Handling.
public protocol Feature {
   associatedtype State: FeatureState
   associatedtype Action: FeatureAction
   associatedtype Event: FeatureEvent
   associatedtype Error: FeatureError
   associatedtype Reducer: FeatureReducer
   associatedtype View: FeatureView
}

/// A helper to declare a `Store` of a `Feature` type.
public typealias FeatureStore<F: Feature> = Store<F.State, F.Action>

Excerpt of Feature.swift (part of a helper module)

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:

import AppFoundation
import AppUI

public enum AppInfoFeature: Feature {
   public struct State: FeatureState {
      // see below
   }

   public enum Action: FeatureAction, BindableAction {
      // see below
   }

   public enum Event: String, FeatureEvent {
      // see below
   }

   public enum Error: FeatureError {
      // ...
   }

   public struct Reducer: FeatureReducer {
      // see below
   }

   public struct View: FeatureView {
      // see below
   }
}

extension AppInfoFeature.Event: AnalyticsEvent {
   public var idComponents: [String] {
      ["AppInfo", self.rawValue]
   }
}

Overview of AppInfoFeature.swift

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:

import AppFoundation
import AppUI

extension AppInfoFeature.View: View {
   public typealias Action = AppInfoFeature.Action

   public var body: some View {
      WithViewStore(self.store, observe: { $0 }) { viewStore in
         VStack(alignment: .leading, spacing: 20) {
            // same code as before
         }
         .frame(width: 320)
         .padding()
         .onAppear { viewStore.send(.onAppear) }
         .onDisappear { viewStore.send(.onDisappear) }
         .sheet(store: self.store.scope(state: \.$childErrorHandling, action: Action.childErrorHandling)) { childStore in
            HelpfulErrorFeature.View(store: childStore)
         }
      }
   }
}

#if DEBUG
   struct AppInfoView_Previews: PreviewProvider {
      static let store = Store(initialState: AppInfoFeature.State(), reducer: AppInfoFeature.Reducer())

      static var previews: some View {
         AppInfoFeature.View(store: self.store).previewVariants()
      }
   }
#endif

AppInfoFeature*View.swift

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:

import AppFoundation
import AppUI

extension AppInfoFeature.Reducer: Reducer {
   public typealias State = AppInfoFeature.State
   public typealias Action = AppInfoFeature.Action

   enum ShowEnvInfoCopiedId {}

   public var body: some ReducerOf<Self> {
      AnalyticsEventRecorderOf<AppInfoFeature> { state, action 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, .childErrorHandling, .selectedAppIconChanged:
            return nil
         }
      }

      BindingReducer()

      Reduce<State, Action> { state, action in
         switch action {
         case .onAppear, .onDisappear:
         return .none  // for analytics only

      case .selectedAppIconChanged:
         return self.selectedAppIconChanged(state: &state)

      case .copyEnvironmentInfoPressed:
         return self.copyEnvironmentInfoPressed(state: &state)

      case .binding:
         return .none  // assignment handled by `BindingReducer()` above

      case .errorOccurred, .childErrorHandling:
         return self.handleHelpfulErrorAction(state: &state, action: action)
         }
      }
      .ifLet(\.$childErrorHandling, action: /Action.childErrorHandling) {
         HelpfulErrorFeature.Reducer()
      }
   }
   
   private func selectedAppIconChanged(...)
   
   private func copyEnvironmentInfoPressed(state: inout State) -> Effect<Action> {
      Pasteboard.string = Constants.GitHub.environmentInfo
      state.showEnvInfoCopiedToClipboard = true

      return .run { send in
         try await self.clock.sleep(for: Constants.toastMessageDuration)
         try Task.checkCancellation()
         await send(.set(\.$showEnvInfoCopiedToClipboard, false))
      }
      .cancellable(id: ShowEnvInfoCopiedId.self, cancelInFlight: true)
   }
   
   private func handleHelpfulErrorAction(...)
}

AppInfoFeature*Reducer.swift

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:

      Pasteboard.string = Constants.GitHub.environmentInfo
      state.showEnvInfoCopiedToClipboard = true

      return .init(value: .set(\.$showEnvInfoCopiedToClipboard, false))
         .delay(for: Constants.toastMessageDuration, scheduler: env.mainQueue)
         .eraseToEffect()
         .cancellable(id: ShowEnvInfoCopiedId.self, cancelInFlight: true)

Excerpt from the old AppInfoActionHandler.swift

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:

import ComposableArchitecture
import Foundation

/// Returns a `Reducer` where each action coming to the store attempts to record an analytics event.
public struct AnalyticsEventRecorder<State, Action, Event: AnalyticsEvent>: Reducer {
   let toAttributedEvent: (State, Action) -> Analytics.AttributedEvent<Event>?

   /// In the event closure, switch over `action` and return an ``Analytics.AttributedEvent`` if the action should be recorded, or else return `nil`.
   public init(event toAttributedEvent: @escaping (State, Action) -> Analytics.AttributedEvent<Event>?) {
      self.toAttributedEvent = toAttributedEvent
   }

   public func reduce(into state: inout State, action: Action) -> Effect<Action> {
      if let attributedEvent = self.toAttributedEvent(state, action) {
         Analytics.shared.record(attributedEvent: attributedEvent)
      }

      return .none
   }
}

/// Convenient way to declare an `AnalyticsEventRecorder`, but requires `Reducer` to conform to `Feature`.
public typealias AnalyticsEventRecorderOf<F: Feature> = AnalyticsEventRecorder<F.State, F.Action, F.Event>

AnalyticsEventRecorder.swift (from a helper module)

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:

  1. Concurrency Beta
  2. Migrating to the Reducer protocol
  3. Composable Navigation Beta

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.

💁🏻‍♂️
Enjoyed this article? Check out TranslateKit!
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!
👨🏻‍💻
Want to Connect?Follow me on 👾Twitch, 🎬YouTube, 🐦Twitter, and 🦣Mastodon.