Skip to main content
TCA leverages Swift’s Observation framework (iOS 17+) and provides a backport called Perception for earlier iOS versions (iOS 13+).

Overview

Starting with version 1.7, The Composable Architecture supports Swift 5.9’s Observation framework and includes a backport called Perception for iOS 13 and later. Source: ObservationBackport.md:1-11

ObservableState Macro

The @ObservableState macro marks your state as observable:
@Reducer
struct Feature {
  @ObservableState
  struct State {
    var count: Int = 0
    var isLoading: Bool = false
    var items: [Item] = []
  }
  
  enum Action {
    case incrementTapped
    case loadData
  }
}
This macro:
  • Conforms your state to the ObservableState protocol
  • Generates observation infrastructure
  • Enables automatic view updates in SwiftUI
  • Works on both iOS 17+ (using Observation) and iOS 13+ (using Perception)
Source: ObservableState.swift:1-19

iOS 17+ (Native Observation)

On iOS 17 and later, TCA uses Swift’s native Observation framework:
import SwiftUI
import ComposableArchitecture

struct FeatureView: View {
  let store: StoreOf<Feature>
  
  var body: some View {
    // Automatically observes changes
    VStack {
      Text("Count: \(store.count)")
      Button("Increment") {
        store.send(.incrementTapped)
      }
    }
  }
}
No special view wrappers are needed—observation works automatically.

Using @Bindable

For two-way bindings on iOS 17+, use SwiftUI’s @Bindable:
struct FeatureView: View {
  @Bindable var store: StoreOf<Feature>
  
  var body: some View {
    Form {
      TextField("Name", text: $store.name)
      Toggle("Enabled", isOn: $store.isEnabled)
    }
  }
}

iOS 13-16 (Perception Backport)

For iOS 13 through 16, TCA provides the Perception framework as a backport.

The @Perceptible Macro

For standalone models (not TCA state), use @Perceptible instead of @Observable:
import Perception

@Perceptible
class CounterModel {
  var count = 0
}
Source: ObservationBackport.md:19-26

WithPerceptionTracking

When using Perception on iOS 13-16, wrap your view body in WithPerceptionTracking:
struct FeatureView: View {
  let store: StoreOf<Feature>
  
  var body: some View {
    WithPerceptionTracking {
      VStack {
        Text("Count: \(store.count)")
        Button("Increment") {
          store.send(.incrementTapped)
        }
      }
    }
  }
}
Source: ObservationBackport.md:28-45
If you access perceptible state outside of WithPerceptionTracking, you’ll get a runtime warning:
🟣 Runtime Warning: Perceptible state was accessed but is not being tracked. Track changes to state by wrapping your view in a ‘WithPerceptionTracking’ view.
Source: ObservationBackport.md:50-58

Perception.Bindable

For bindings on iOS 13-16, use Perception.Bindable instead of SwiftUI’s @Bindable:
struct FeatureView: View {
  @Perception.Bindable var store: StoreOf<Feature>
  
  var body: some View {
    WithPerceptionTracking {
      Form {
        TextField("Name", text: $store.name)
        Toggle("Enabled", isOn: $store.isEnabled)
      }
    }
  }
}
Source: ObservationBackport.md:62-81

Cross-Platform Pattern

For apps supporting both iOS 17+ and earlier versions:
import SwiftUI
import ComposableArchitecture

struct FeatureView: View {
  #if swift(>=5.9)
    @Bindable var store: StoreOf<Feature>
  #else
    @Perception.Bindable var store: StoreOf<Feature>
  #endif
  
  var body: some View {
    #if swift(>=5.9)
      content
    #else
      WithPerceptionTracking {
        content
      }
    #endif
  }
  
  @ViewBuilder
  var content: some View {
    Form {
      TextField("Name", text: $store.name)
      Button("Submit") {
        store.send(.submitTapped)
      }
    }
  }
}

Lazy View Closures

Many SwiftUI closures are lazy and execute after the body is computed. These require their own WithPerceptionTracking:
struct ListView: View {
  let store: StoreOf<Feature>
  
  var body: some View {
    WithPerceptionTracking {
      List {
        ForEach(store.items, id: \.id) { item in
          // ❌ Wrong: This closure runs outside WithPerceptionTracking
          Text(item.title)
        }
      }
    }
  }
}
Fix by wrapping the lazy closure content:
struct ListView: View {
  let store: StoreOf<Feature>
  
  var body: some View {
    WithPerceptionTracking {
      List {
        ForEach(store.items, id: \.id) { item in
          WithPerceptionTracking {
            // ✅ Correct: Closure content is tracked
            Text(item.title)
          }
        }
      }
    }
  }
}
Source: ObservationBackport.md:85-116

Common Lazy Closures

These SwiftUI closures are lazy and need their own WithPerceptionTracking:
  • ForEach { ... }
  • List { ... }
  • LazyVStack { ... } and LazyHStack { ... }
  • NavigationLink(destination: { ... })
  • .background { ... }
  • .overlay { ... }
  • .sheet(item:) { ... }
  • .fullScreenCover(item:) { ... }

ObservableState Protocol

The ObservableState protocol is the foundation of TCA’s observation:
public protocol ObservableState: Perceptible {
  var _$id: ObservableStateID { get }
  mutating func _$willModify()
}
Source: ObservableState.swift:3-13

Identity Tracking

TCA tracks state identity to optimize view updates:
public struct ObservableStateID: Equatable, Hashable, Sendable {
  // Unique identifier for state instances
}
Source: ObservableState.swift:21-103

Store Observation

The Store type integrates with both Observation and Perception:
extension Store: Perceptible {}

extension Store where State: ObservableState {
  var observableState: State {
    self._$observationRegistrar.access(self, keyPath: \.currentState)
    return self.currentState
  }
  
  public var state: State {
    self.observableState
  }
}
Source: Store+Observation.swift:7-20

Mixing Legacy and Modern Features

Problems can arise when mixing legacy features (using ViewStore/WithViewStore) with modern features (using @ObservableState):
  • Views may re-compute more often than necessary
  • SwiftUI may struggle to determine what changed
  • Navigation bugs may occur or worsen
Source: ObservationBackport.md:118-127 Migrate features incrementally:
// Old (Legacy)
@Reducer
struct OldFeature {
  struct State: Equatable {
    var count: Int
  }
  // Uses WithViewStore in views
}

// New (Modern)
@Reducer
struct NewFeature {
  @ObservableState
  struct State {
    var count: Int
  }
  // Direct store access in views
}

Platform Differences

visionOS

On visionOS, TCA always uses native Observation:
#if !os(visionOS)
  extension Store: Perceptible {}
#else
  // Uses native Observable
#endif
Source: Store+Observation.swift:7-9

iOS 17+

On iOS 17 and later, Store conforms to Observable:
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
extension Store: Observable {}
Source: Store.swift:574-579

Best Practices

Always Use @ObservableState

Even if you’re only supporting iOS 17+, use @ObservableState instead of @Observable:
// ✅ Correct: Works on all platforms
@Reducer
struct Feature {
  @ObservableState
  struct State { }
}

// ❌ Wrong: Only works on iOS 17+
@Reducer
struct Feature {
  @Observable
  class State { }  // Don't use class for state!
}

Wrap All View Bodies (iOS 13-16)

Consistently use WithPerceptionTracking:
struct FeatureView: View {
  let store: StoreOf<Feature>
  
  var body: some View {
    WithPerceptionTracking {
      // All view content
    }
  }
}

Check for Runtime Warnings

If you see the purple runtime warning, check the stack trace to find where you’re accessing state without tracking:
  1. Open the Issue Navigator (⌘5)
  2. Expand the warning
  3. Click through stack frames
  4. Find the line accessing state
  5. Add WithPerceptionTracking
Source: ObservationBackport.md:50-58

Prefer Structs for State

Always use structs for state, not classes:
// ✅ Correct
@ObservableState
struct State {
  var value: Int
}

// ❌ Wrong
@Perceptible
class State {
  var value: Int
}

Performance Considerations

Observation Overhead

The observation system has minimal overhead:
  • State access is tracked automatically
  • Only changed properties trigger view updates
  • Identity-based diffing optimizes collections

Fine-Grained Updates

Observation enables fine-grained view updates:
@ObservableState
struct State {
  var firstName: String
  var lastName: String
  var email: String
}

struct NameView: View {
  let store: StoreOf<Feature>
  
  var body: some View {
    // Only updates when firstName or lastName change
    // NOT when email changes
    Text("\(store.firstName) \(store.lastName)")
  }
}

Debugging

Skip Perception Checking

For debugging, you can temporarily skip perception checking:
#if DEBUG
  _PerceptionLocals.$skipPerceptionChecking.withValue(true) {
    // Code that accesses state without tracking
  }
#endif
Source: Store.swift:167-174

Observation Registrar

The store uses an observation registrar internally:
#if !os(visionOS)
  let _$observationRegistrar = PerceptionRegistrar(
    isPerceptionCheckingEnabled: _isStorePerceptionCheckingEnabled
  )
#else
  let _$observationRegistrar = ObservationRegistrar()
#endif
Source: Store.swift:110-116

Build docs developers (and LLMs) love