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.
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.
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.
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.
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.
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 toSendable
so thatsome ReducerOf<T>
conforms toSendable
. - 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")
}
}
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)
}
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
.
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.
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.
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)
}
}
}
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.