The Composable Architecture (TCA) Setup for iOS App
TCA from Point-Free — not just another way to arrange folders in Xcode. This strict unidirectional data flow where entire app state changes only through Reducer, and each change tests deterministically. If working with SwiftUI, complex navigation and large team — TCA provides tooling MVVM doesn't have.
Main concepts in code
Store, State, Action, Reducer, Effect — five TCA pillars.
State — struct describing everything screen needs. Action — enum with associated values describing everything that can happen. Reducer — pure function (State, Action) -> Effect<Action>. Effect — wrapper over async work (network, timers, MotionManager).
@Reducer
struct ProfileFeature {
@ObservableState
struct State: Equatable {
var user: UserProfile?
var isLoading = false
var errorMessage: String?
}
enum Action {
case loadProfile(id: String)
case profileLoaded(Result<UserProfile, Error>)
case editButtonTapped
}
@Dependency(\.userClient) var userClient
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case let .loadProfile(id):
state.isLoading = true
return .run { send in
await send(.profileLoaded(
Result { try await userClient.fetch(id) }
))
}
case let .profileLoaded(.success(user)):
state.isLoading = false
state.user = user
return .none
case let .profileLoaded(.failure(error)):
state.isLoading = false
state.errorMessage = error.localizedDescription
return .none
case .editButtonTapped:
return .none
}
}
}
}
View contains no logic: store.send(.loadProfile(id: userId)) and store.user — entire interaction via Store.
Composition: where TCA really shines
TCA's main strength — scope and composability. Large app assembled from small Reducers:
@Reducer
struct AppFeature {
struct State {
var profile = ProfileFeature.State()
var feed = FeedFeature.State()
}
enum Action {
case profile(ProfileFeature.Action)
case feed(FeedFeature.Action)
}
var body: some Reducer<State, Action> {
Scope(state: \.profile, action: \.profile) { ProfileFeature() }
Scope(state: \.feed, action: \.feed) { FeedFeature() }
}
}
Each module developed independently. ProfileFeature knows nothing of FeedFeature. This splits team into isolated development streams.
Dependency system — replacement for singletons
TCA comes with DependencyValues — DI mechanism replacing URLSession.shared and UserDefaults.standard in production with test stubs. Not Service Locator: dependencies declared explicitly via @Dependency(\.userClient).
extension DependencyValues {
var userClient: UserClient {
get { self[UserClientKey.self] }
set { self[UserClientKey.self] = newValue }
}
}
In tests: withDependencies { $0.userClient = .mock } { ... }. No protocol stub, no setUp/tearDown with global state.
Tests: deterministic TestStore
func test_loadProfile_success() async {
let store = TestStore(initialState: ProfileFeature.State()) {
ProfileFeature()
} withDependencies: {
$0.userClient.fetch = { _ in .stub(id: "42") }
}
await store.send(.loadProfile(id: "42")) {
$0.isLoading = true
}
await store.receive(.profileLoaded(.success(.stub(id: "42")))) {
$0.isLoading = false
$0.user = .stub(id: "42")
}
}
TestStore requires explicitly describing each state change. If something changed but wasn't described — test fails. Expensive testing to write, but completely excludes state regressions.
Navigation in TCA: NavigationStack and tree-based
TCA 1.x added NavigationStack support via StackState/StackAction. Alternative — PresentationState/PresentationAction for sheets, alerts, popovers. All navigation states — part of State, serializable and testable:
@Reducer
struct AppFeature {
struct State {
var path = StackState<Path.State>()
}
@Reducer enum Path {
case profile(ProfileFeature)
case settings(SettingsFeature)
}
}
Deep link opens via store.send(.setPath([.profile(...), .settings(...)])). Test checks navigation stack state without UI.
When TCA unnecessary
Small app (5–10 screens, one developer) — TCA adds boilerplate without proportional benefit. MVVM + Combine or even @StateObject with services cheaper.
TCA pays for itself with: team 3+ people, complex navigation with deep links, 80%+ test coverage requirement, features with real parallelism (sync/async effects, timers, WebSocket).
What we do during setup
Add TCA via Swift Package Manager (swift-composable-architecture current version). Set up first Reducer — as example for team with TestStore coverage. Migrate existing logic from ViewModel/ViewController to TCA modules per-screen.
Team training: break down on real project code, not abstract examples.
Timelines
TCA setup from scratch + first 3 screens with tests: 5–8 days. Existing MVVM project migration to TCA (10–20 screens): 3–6 weeks. Cost — after analyzing volume and current architecture.







