Skip to content

Instantly share code, notes, and snippets.

@lukeredpath
Last active December 10, 2024 05:47
Show Gist options
  • Save lukeredpath/a04051224bedffad3fdac3aeb1c6a124 to your computer and use it in GitHub Desktop.
Save lukeredpath/a04051224bedffad3fdac3aeb1c6a124 to your computer and use it in GitHub Desktop.
Converting a TCA app to Swift 6

I maintain the Community mobile app - a moderately large codebase that is fully modularized using Swift Package Manager and uses The Composable Architecture. I have recently completed the process of getting the project ready for Xcode 16 and Swift 6 and I wanted to outline the approach I took and some of the issues I encountered, especially regarding TCA.

The Approach

There are already good guides out there for how to incrementally migrate your project over to Swift 6 including some of the content from this year's WWDC. This article isn't intended to be a fully in-depth guide to Swift 6 and strict concurrency however I feel its useful to outline the approach I took.

Our app is fully modularized - the main app target contains only a small number of files: the App entry point, an AppDelegate implementation, the usual Xcode project resources such as the app icon and Info.plist file and not much else. The rest of our codebase is split into two local Swift packages - a Core package and a Features package.

Each feature of our app has a library/target in the features package. A "feature" might correspond to a single screen, multiple screens or an entire section of the app, depending on the feature complexity or whether it needs to be re-used in across different parts of the app. The core package contains all of the libraries and code that is used throughout each feature including things like API clients, model entities, utilities, dependencies etc.

Most targets in each package have a corresponding test target. We also have an app test target with a small number of integration tests that need to be run inside a host app.

This structure made it a lot easier to incrementally migrate. I started with the Core package, working through each library/target in turn, before moving on to the features. As I worked on each target, I would enable strict concurrency checking:

.target(
    name: "Example",
    dependencies: [],
    swiftSettings: [
      .enableExperimentalFeature("StrictConcurrency"),
      .enableUpcomingFeature("InferSendableFromCaptures")
    ]
)

You can choose to use a lower level of concurrency checking - I opted to go straight for strict concurrency checking. I also enabled the InferSendableFromCaptures as I found this resolved a lot of warnings I had around key paths and Sendable.

Once I reached a point where all but a handful of my core modules were building without warning, I switched the entire package over to the 6.0 tools version which in Xcode 16 is all you need to do to enable Swift 6 language mode for the entire package. You can then opt back into Swift 5 mode for certain packages:

.target(
    name: "Example",
    dependencies: [],
    swiftSettings: [
      .swiftLanguageVersion(.v5)
    ]
)

Once both packages were fully migrated, I also enabled Swift 6 mode in the Xcode project settings for our main app target and fixed any remaining warnings/errors.

Common Problems

As I write this, the diff for my Xcode 16/Swift 6 branch is sitting at around +4914/-4170 lines. As well as migrating to Swift 6, we are also preparing to raise our minimum deployment target to iOS 17 so this branch also contains some updates to/removal of some iOS 16 specific code but the vast majority of this diff is marking types as Sendable. For most codebases, marking types as Sendable will be approximately 80% of the work.

As the documentation explains, most value types should be sendable but in a modular codebase where most of your types are marked as public this will require you to explicitly mark those types as Sendable. The vast majority of structs and enums in our codebase have been marked as Sendable, including TCA reducer and state types (more on that later).

Another common issue you may face is dealing with third-party SDKs and libraries that have not been updated to work with strict concurrency. In some cases not all Apple frameworks are fully ready for Swift 6 and strict concurrency. In our codebase we currently have 10 instances of @preconcurrency import - all but three of these are for third party frameworks. Note: try to use @preconcurrency import as a last resort, especially for Apple frameworks. Its often better to have a localized specific workaround than blanket disabling all warnings/errors for a particular library/framework.

TCA and Strict Concurrency

If your app is built using TCA, there are a number of issues to watch out for when converting your own TCA-codebase. I have tried to outline the ones I encountered here and some solutions. A lot of the Pointfree libraries are well on their way to being ready for Swift 6 and strict concurrency but they are not fully ready just yet. In particular, not everything in TCA you might expect to be Sendable is right now.

Reducers

Probably one of the first things you'll notice when trying to compile a TCA feature with strict concurrency turned on (or in Swift 6 mode), is the following warning/error (remember that most strict concurrency warnings are actual errors in Swift 6 mode):

Capture of 'self' with non-sendable type 'MyReducer' in a `@Sendable` closure

If you have any .run effects in your reducer that use an @Dependency declared as a property on your reducer, you will see this. The cause of this is that accessing the dependency implicitly captures self inside the .run closure, which takes a @Sendable closure.

One workaround for this is to not use @Dependency properties inside your reducer and just declare them locally where you need them:

return .run { send in
  @Dependency(\.foo) var foo
  // use foo here
}

Whilst this will work, its very inconvenient - I like being able to see at a glance which dependencies a reducer is using. So, to solve this issue you will want to mark all of your reducers as Sendable. In general this is straightforward and hopefully in a future version of TCA, the @Reducer macro will do this automatically.

Higher-order reducers

One issue you might run into with making all of your reducers Sendable is if you have any reusable high-level reducers in your project, especially ones whose main API is an instance method on the Reducer type that you chain on to other reducers, like the built-in ifLet and onChange reducers. Depending on what your reducer does, you can run into problems with reducer ordering and generic constraints.

This can be demonstrated with an example - imagine a high-level reducer that calls a closure with a Logger that gets called before it calls an upstream reducer, allowing you to log something before the reducer it is attached to is called.

struct ProxyReducer<Upstream: Reducer>: Reducer {
  let upstream: Upstream
  let log: (Logger) -> Void

  @Dependency(\.logger)
  private var logger

  var body: some ReducerOf<Upstream> {
    Reduce { state, action in
      log(logger)
      return upstream.reduce(into: &state, action: action)
    }
  }
}

extension Reducer {
  public func logging(_ log: @escaping (Logger) -> Void) -> some ReducerOf<Self>
    ProxyReducer(upstream: self, log: log)
  }
}

Rather than creating an instance of this reducer directly, we call an instance method on the upstream reducer:

var body: some ReducerOf<Self> {
  Reduce {
    ...
  }
  .logging {
    $0.info("Calling my feature reducer")
  }
}

How would we go about making this Sendable? We'd of course need to conform ProxyReducer to Sendable, but that would also require us to make sure its stored properties are also Sendable:

struct ProxyReducer<Upstream: Reducer & Sendable>: Reducer, Sendable {
  let upstream: Upstream
  let log: @Sendable (Logger) -> Void

  @Dependency(\.logger)
  private var logger

  var body: some ReducerOf<Upstream> {
    Reduce { state, action in
      log(logger)
      return upstream.reduce(into: &state, action: action)
    }
  }
}

Note that in order to store the upstream reducer we have had to further constrain the Upstream generic to Sendable. We could instead just make the log closure @Sendable and then conditionally conform ProxyReducer to Sendable only when Upstream is Sendable but lets assume we just want to go all-in and only allow this to be used on Sendable reducers.

We can now continue to use this API on Sendable conforming reducers, but what happens if we make a small change to our existing feature reducer, e.g. we want to track some changes to the state:

var body: some ReducerOf<Self> {
  Reduce {
    ...
  }
  .onChange(of: \.foo) { _, _ in
    ...
  }
  .logging {
    $0.info("Calling my feature reducer")
  }
}

This no longer compiles - .logging() requires that the upstream reducer conforms to Sendable but onChange returns an _OnChangeReducer which is a built-in TCA reducer that does not conform to Sendable - in fact, none of the TCA reducers currently conform to Sendable. A similar issue can be found with ifLet:

var body: some ReducerOf<Self> {
  Reduce {
    ...
  }
  .ifLet(\.$destination, action: \.destination)
    ...
  }
  .logging {
    $0.info("Calling my feature reducer")
  }
}

In this case, ifLet returns an opaque type some ReducerOf<Self> - all the compiler knows about the return type is that it conforms to Reducer but not Sendable. This issue could be fixed in TCA in one of two ways:

  • Have the Reducer protocol conform to Sendable so that some ReducerOf<T> conforms to Sendable.
  • Conform all built-in reducers to Sendable and always have higher-order reducer functions return the concrete type (like the _OnChangeReducer above) rather than opaque types.

In the meantime, how can we deal with this in our own codebases? One option is to just be careful about the order of your higher-order reducer function calls but this might not always be helpful - what if we wanted the logging to happen after the ifLet or onChange in the above example?

Fortunately, there is a solution and it involves another higher-order reducer that I introduced to our project to workaround this specific problem, which I call UncheckedSendableReducer. It is effectively a proxy to an upstream reducer that erases its type to a new type that conforms to Sendable and does so by storing any non-Sendable upstream in an UncheckedSendable wrapper.

public struct UncheckedSendableReducer<Upstream: Reducer>: Reducer, Sendable {
    let reducer: UncheckedSendable<Upstream>

    init(reducer: Upstream) {
        self.reducer = .init(reducer)
    }

    public var body: some ReducerOf<Upstream> {
        Reduce { state, action in
            return reducer.value.reduce(into: &state, action: action)
        }
    }
}

public extension Reducer {
    func eraseToUncheckedSendable() -> UncheckedSendableReducer<Self> {
        UncheckedSendableReducer(reducer: self)
    }
}

Now all we need to do is call eraseToUncheckedSendable() before our own higher-order reducer function - another advantage of this workaround is that it is easy to remove in future once TCA has been updated to solve this problem more directly:

```swift
var body: some ReducerOf<Self> {
  Reduce {
    ...
  }
  .ifLet(\.$destination, action: \.destination)
    ...
  }
  .eraseToUncheckedSendable()
  .logging {
    $0.info("Calling my feature reducer")
  }
}

Reducer State

If we've made all of our reducers sendable, what about their state? Does that need to be Sendable as well? From my experience, the answer to this is probably. It is possible to migrate a TCA codebase without making your state sendable but you will have an easier time if you do.

State in TCA is accessed from three places:

  • The reducer body - this is where you read and mutate your state.
  • The view - your view transforms your store's state into your UI.
  • In effects - you may need to pass values from your state to dependencies when performing effects or use state to conditionally run certain effects.

Neither of the first two require your state to be sendable, but the third one might. Again, because .run effects take a @Sendable closure, anything captured by that closure also needs to be Sendable:

return .run { [state] send in
  try await api.request(.foo(value: state.value))
}

You can often work around this by only capturing the specific properties you need and if they are already sendable types, you might not need to mark your State as sendable:

return .run { [sendableValue = state.sendableValue] send in
  try await api.request(.foo(value: sendableValue))
}

However, I have generally found it simpler to make all state types Sendable. It avoids having long capture lists cluttering up reducer code and as state types are just structs and enums, marking them as Sendable should be as simple as annotating the State struct/enum and any of your custom types that you use inside them too.

If your state holds on to some destination or path state, don't forget to also make that sendable:

@Reducer(state: .equatable, .sendable)
enum Destination {
  case one(OneFeature)
}

Reducer Actions

The final component of a TCA feature are the actions - generally an enum with each case representing an action that can be sent from your view or an effect. Do these need to be Sendable? For the most part, no. Action values are created where they are used and do not currently cross actor boundaries - they are passed to either store.send or the send function in a .run effect, neither of which require the action to be Sendable.

One exception are actions intended to be used for alerts and confirmation dialogs - the presentation of these are driven by values of type AlertState and ConfirmationDialogState that are stored in your state which are conditionally Sendable if the associated action is also Sendable. If you have made your State conform to Sendable then in order for these properties to also be Sendable, their actions must conform to Sendable too.

Another potential issue you may find is when you have dependencies that return a stream of actions or events that you feed back into the store from an effect. This is a common way of wrapping up legacy APIs that use the delegate pattern in an async interface - your dependency has a function that returns an AsyncStream of events with each representing a delegate call - you may call these actions or events, but in our codebase we use this pattern in a few places and pass these "actions" directly back into the store as if they were any other action. For instance, given a dependency like this:

struct SomeDelegateDependency: Sendable {
  var start: () -> AsyncStream<Action>

  enum Action: Sendable {
    case delegateMethodOne
    case delegtaeMethodTwo
  }
}

We might integrate this into a feature by sending its actions back to the reducer:

@Reducer
struct Feature {
  struct State { ... }

  enum Action {
    case someDependency(SomeDelegateDependency.Action)
  }

  @Dependency(\.client) var client

  var body: some ReducerOf<Self> {
    Reduce { _, _ in
      return .run { send in
        let stream = client.start()
        for await action in stream {
          await send(.someDependency(action))
        }
      }
    }
  }
}

Because actions should be sent to the store on the main thread, the Send value passed to all TCA effects is annotated as @MainActor - this means that the actions being produced by the stream need to cross a boundary and if they are not marked as Sendable, you will see a warning:

Sending value of non-Sendable type 'SomeDelegateDependency.Action' with later accesses from nonisolated context to main actor-isolated context risks causing data races

If you use this pattern in any of your dependencies, you will need to mark their actions as Sendable.

Stores and concurrency boundaries

As mentioned above, the Store type is intended to be used from a single thread - the main thread - but it does not currently use main actor isolation and is also not marked as Sendable. In general this doesn't cause a problem as you typically create a root store in the root of your app inside a main actor context and pass it down through the view hierarchy, meaning it never crosses any boundaries.

The one exception to this is when you need to pass your Store to a Sendable closure, which you may occasionally need to do. In our app we do this for two main reasons:

  • Creating custom bindings that need to query a store or send actions.
  • SwiftUI environment actions.

Custom Bindings

TCA makes it very easy to derive bindings from a store however there are times when you might need to create a binding with custom logic. Because the get and set closures are both @Sendable if you capture the a store in these closures you will get a warning/error. To solve this, you can use UncheckedSendable to safely pass the store across the boundary - we know that the binding will be called from the main actor and so passing the store in this way is safe, we just need a way of letting the compiler know that:

private var customBinding: Binding<Bool> {
 let _store = UncheckedSendable(store)
 return Binding(
   get: { _store.foo == "whatever" }
   set: { _store.send(.whatever) }  
 )
}

However, while this approach works, there is a better way. Creating brand new bindings from scratch can sometimes cause unexpected behaviour in SwiftUI, especially around animations. It is often better to derive a binding from an existing one.

When using an @Bindable to derive bindings to some store state, you can create custom bindings using a computed property on an extension of the store. We can re-write the above example as:

fileprivate extension StoreOf<SomeFeature> {
 var customProperty: Bool {
   get { self.foo == "whatever" }
   set { self.send(.whatever) }
 }
}

This can now be called directly on an existing store to obtain a binding:

SomeComponent(isEnabled: $store.customProperty)

If you derive a custom binding using a function that takes some kind of input parameter, you can also use a custom subscript. See the documentation for more information.

Environment Actions

Similarly, we have a few custom environment actions that send actions to a store higher up the view/store hierarchy that we can call from deeper within the view hierarchy. This can be a useful pattern for sending an action to a particular domain without the child view needing to know anything about it.

Environment actions are typically defined as a struct that holds on to a closure:

struct LogoutAction {
  var logout: () -> Void

  func callAsFunction() {
    logout()
  }
}

extension EnvironmentValues {
 @Entry var logoutAction = LogoutAction(logout: {})
}

If you've ever defined a custom environment value before, you'll know that it relies on a key that conforms to EnvironmentKey with a static defaultValue. The new @Entry macro in iOS 18 still generates this key under the hood. Trying to store a static defaultValue of a non-Sendable type is not concurrency safe so we need to conform it as Sendable. Also, because this is designed to be used from a view, we will also annotate it as @MainActor:

struct LogoutAction: Sendable {
  var logout: @Sendable @MainActor () -> Void

  @MainActor
  func callAsFunction() {
    logout()
  }
}

We can now define the runtime implementation to call our store somewhere in our view hierarchy:

var body: some View {
  LoggedInView(store: store)
    .transformEnvironment(\.self) {
      $0.logoutAction = .init {
        store.send(.logoutButtonTapped)
      }
    }
}

Like before, we are unable to pass the store across the concurrency boundary as is not Sendable - UncheckedSendable to the rescue once more:

var body: some View {
  LoggedInView(store: store)
    .transformEnvironment(\.self) {
      let _store = UncheckedSendable(store)
      $0.logoutAction = .init {
        _store.send(.logoutButtonTapped)
      }
    }
}

Conclusion

Hopefully this article demonstrates that converting a TCA app to be ready for strict concurrency checking and Swift 6 is not difficult, although depending on the size of your app it make take some time to work your way through some of the issues outlined above.

I will aim to keep this article updated as I discover any other issues or better workarounds for some of the issues highlighted.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment