Skip to main content
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 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 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
}

Platform Availability

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

Build docs developers (and LLMs) love