Настройка архітектури The Composable Architecture (TCA) для iOS-застосунку
TCA від Point-Free — не просто ще один спосіб розташувати папки в Xcode. Це строгий однонаправлений потік даних, де стан всього застосунку змінюється тільки через Reducer, та кожна зміна тестується детерміновано. Якщо ви працюєте з SwiftUI, складною навігацією та великою командою — TCA дає інструментарій, якого немає у MVVM.
Основні концепції в коді
Store, State, Action, Reducer, Effect — п'ять китів TCA.
State — структура, що описує все, що потрібно екрану. Action — enum з пов'язаними значеннями, що описує все, що може статися. Reducer — чиста функція (State, Action) -> Effect<Action>. Effect — обгортка над асинхронною роботою (мережа, таймери, 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 не містить логіки: store.send(.loadProfile(id: userId)) та store.user — вся взаємодія через Store.
Composition: де TCA реально блищить
Головна сила TCA — scope та composability. Великий застосунок собирається з малих Reducer-ів:
@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() }
}
}
Кожен модуль розробляється незалежно. ProfileFeature нічого не знає про FeedFeature. Це дозволяє розділити команду на ізольовані потоки розробки.
Dependency system — замена синглтонам
TCA поставляється з DependencyValues — механізмом ін'єкції залежностей, який замінює URLSession.shared та UserDefaults.standard у продакшені на тестові заглушки. Це не Service Locator: залежності об'являються явно через @Dependency(\.userClient).
extension DependencyValues {
var userClient: UserClient {
get { self[UserClientKey.self] }
set { self[UserClientKey.self] = newValue }
}
}
У тестах: withDependencies { $0.userClient = .mock } { ... }. Ніякого протоколу-заглушки, ніяких setUp/tearDown з глобальним станом.
Тести: детерміністичний 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 вимагає явно описати кожну зміну стану. Якщо щось змінилось, але не описано — тест падає. Це дорогостоюче тестування писати, але воно повністю виключає регресії по стану.
Навігація в TCA: NavigationStack та tree-based
З TCA 1.x появилась підтримка NavigationStack через StackState/StackAction. Альтернатива — PresentationState/PresentationAction для sheets, alerts, popovers. Всі навігаційні стани — частина State, серіалізуємі та тестуємі:
@Reducer
struct AppFeature {
struct State {
var path = StackState<Path.State>()
}
@Reducer enum Path {
case profile(ProfileFeature)
case settings(SettingsFeature)
}
}
Deep link відкривається через store.send(.setPath([.profile(...), .settings(...)])). Тест перевіряє стан навігаційного стеку без UI.
Коли TCA не потрібна
Невеликий застосунок (5–10 екранів, один розробник) — TCA додасть boilerplate без пропорціональної вигоди. MVVM + Combine або навіть @StateObject з сервісами дешевше.
TCA окупається при: команді від 3 осіб, складній навігації з deep links, вимозі 80%+ тестового покриття, фічах з реальним паралелізмом (sync/async ефекти, таймери, WebSocket).
Що робимо при настройці
Додаємо TCA через Swift Package Manager (поточна версія swift-composable-architecture). Настраюємо перший Reducer — як образець для команди з покриттям через TestStore. Переносимо існуючу логіку з ViewModel/ViewController у TCA-модулі поекранно.
Навчання команди: розбираємо на реальному коді проекту, не на абстрактних прикладах.
Терміни
Настройка TCA з нуля + перші 3 екрани з тестами: 5–8 днів. Міграція існуючого MVVM-проекту на TCA (10–20 екранів): 3–6 тижнів. Вартість — після аналізу обсягу та поточної архітектури.







