While TCA was designed with SwiftUI in mind, it provides comprehensive tools for UIKit applications through the UIKitNavigation framework.
Basic UIKit Integration
Using @UIBindable
The @UIBindable property wrapper enables observation and binding creation in UIKit view controllers:
import ComposableArchitecture
import UIKit
class FeatureViewController: UIViewController {
@UIBindable var store: StoreOf<Feature>
init(store: StoreOf<Feature>) {
self.store = store
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
}
}
Using @ViewAction
The @ViewAction macro simplifies sending actions from view controllers:
@ViewAction(for: Feature.self)
class FeatureViewController: UIViewController {
@UIBindable var store: StoreOf<Feature>
override func viewDidLoad() {
super.viewDidLoad()
let button = UIButton(
type: .system,
primaryAction: UIAction { [weak self] _ in
self?.send(.buttonTapped)
}
)
}
}
Source: LoginViewController.swift:6-12
Observing State Changes
Use the observe method to react to state changes:
override func viewDidLoad() {
super.viewDidLoad()
let titleLabel = UILabel()
let activityIndicator = UIActivityIndicatorView()
observe { [weak self, weak titleLabel, weak activityIndicator] in
guard let self else { return }
titleLabel?.text = store.title
titleLabel?.isHidden = store.isTitleHidden
if store.isLoading {
activityIndicator?.startAnimating()
} else {
activityIndicator?.stopAnimating()
}
}
}
Source: LoginViewController.swift:86-92
Always use [weak self] and [weak view] captures in observe closures to prevent retain cycles.
Two-Way Bindings
Create UIKit bindings using the $store syntax:
let emailTextField = UITextField(text: $store.email)
emailTextField.placeholder = "[email protected]"
emailTextField.borderStyle = .roundedRect
let passwordTextField = UITextField(text: $store.password)
passwordTextField.placeholder = "Password"
passwordTextField.isSecureTextEntry = true
Source: LoginViewController.swift:42-50
Navigation
Navigation Destinations
Use navigationDestination(item:) to handle push navigation:
override func viewDidLoad() {
super.viewDidLoad()
navigationDestination(
item: $store.scope(state: \.destination, action: \.destination)
) { store in
DestinationViewController(store: store)
}
}
Source: LoginViewController.swift:98-100
Stack-Based Navigation
For more complex navigation, use NavigationStackController:
import UIKitNavigation
let navigationController = NavigationStackController(
path: $store.scope(state: \.path, action: \.path),
root: { RootViewController(store: store) },
destination: { store in
switch store.case {
case .detail:
DetailViewController(store: store)
case .settings:
SettingsViewController(store: store)
}
}
)
Source: NavigationStackControllerUIKit.swift:4-78
Programmatic Navigation
Push to the stack programmatically using UIPushAction:
@available(iOS 17, *)
@MainActor
func pushToDetail() {
let pushAction = UIPushAction()
pushAction(state: DetailFeature.State())
}
Source: NavigationStackControllerUIKit.swift:80-110
Alerts and Dialogs
Presenting Alerts
Use UIAlertController with store scoping:
override func viewDidLoad() {
super.viewDidLoad()
present(item: $store.scope(state: \.alert, action: \.alert)) { store in
UIAlertController(store: store)
}
}
Source: LoginViewController.swift:94-96
Alert State Integration
Define alerts in your feature’s state:
@Reducer
struct Feature {
@ObservableState
struct State {
@Presents var alert: AlertState<Action.Alert>?
}
enum Action {
case alert(PresentationAction<Alert>)
enum Alert {
case confirmDelete
case cancel
}
}
}
The UIAlertController initializer automatically handles the alert presentation:
public convenience init<Action>(
store: Store<AlertState<Action>, Action>
) {
// Automatically presents alert from store state
}
Source: AlertStateUIKit.swift:7-30
Confirmation Dialogs
Similar to alerts, present confirmation dialogs:
present(item: $store.scope(state: \.dialog, action: \.dialog)) { store in
UIAlertController(store: store)
}
Source: AlertStateUIKit.swift:32-54
Modal Presentation
Present view controllers modally using the present(item:) method:
override func viewDidLoad() {
super.viewDidLoad()
present(
item: $store.scope(state: \.sheet, action: \.sheet),
modalPresentationStyle: .formSheet
) { store in
SheetViewController(store: store)
}
}
Custom Bindings
Create custom bindings for UIKit controls:
let slider = UISlider()
observe { [weak self, weak slider] in
guard let self else { return }
slider?.value = Float(store.volume)
}
slider.addAction(
UIAction { [weak self] action in
if let slider = action.sender as? UISlider {
self?.send(.volumeChanged(Double(slider.value)))
}
},
for: .valueChanged
)
Best Practices
Memory Management
Always use weak references in closures:
// Good: Prevents retain cycles
observe { [weak self, weak label] in
guard let self else { return }
label?.text = store.text
}
// Bad: Creates retain cycle
observe {
self.label.text = self.store.text
}
View Lifecycle
Set up observations in viewDidLoad():
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupObservations() // Observe state here
}
private func setupObservations() {
observe { [weak self] in
// Update UI from store
}
}
Combine @UIBindable with @ViewAction
Use both macros together for the best experience:
@ViewAction(for: Feature.self)
class FeatureViewController: UIViewController {
@UIBindable var store: StoreOf<Feature>
// @ViewAction provides the send() method
// @UIBindable provides the $store binding
}
Some UIKit integration features require specific iOS versions:
#if canImport(UIKit) && !os(watchOS)
// UIKit code here
#endif
@available(iOS 17, macOS 14, tvOS 17, *)
func useModernFeature() {
// iOS 17+ features
}
Source: NavigationStackControllerUIKit.swift:1-3
Complete Example
Here’s a complete UIKit view controller example:
import ComposableArchitecture
import UIKit
@ViewAction(for: Login.self)
class LoginViewController: UIViewController {
@UIBindable var store: StoreOf<Login>
init(store: StoreOf<Login>) {
self.store = store
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.title = "Login"
view.backgroundColor = .systemBackground
let emailTextField = UITextField(text: $store.email)
emailTextField.placeholder = "[email protected]"
emailTextField.borderStyle = .roundedRect
let passwordTextField = UITextField(text: $store.password)
passwordTextField.placeholder = "Password"
passwordTextField.isSecureTextEntry = true
let loginButton = UIButton(
type: .system,
primaryAction: UIAction { [weak self] _ in
self?.send(.loginButtonTapped)
}
)
loginButton.setTitle("Login", for: .normal)
let activityIndicator = UIActivityIndicatorView()
// Layout code...
observe { [weak self, weak activityIndicator, weak loginButton] in
guard let self else { return }
loginButton?.isEnabled = !store.isLoading
if store.isLoading {
activityIndicator?.startAnimating()
} else {
activityIndicator?.stopAnimating()
}
}
present(item: $store.scope(state: \.alert, action: \.alert)) { store in
UIAlertController(store: store)
}
navigationDestination(
item: $store.scope(state: \.destination, action: \.destination)
) { store in
DestinationViewController(store: store)
}
}
}
Migration from ViewStore
If you’re migrating from the legacy ViewStore pattern:
// Old pattern (deprecated)
let viewStore = ViewStore(store, observe: { $0 })
viewStore.send(.action)
let value = viewStore.state.value
// New pattern
@UIBindable var store: StoreOf<Feature>
store.send(.action) // Or send(.action) with @ViewAction
let value = store.value