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:
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:
No special view wrappers are needed—observation works automatically.

Using @Bindable

For two-way bindings on iOS 17+, use SwiftUI’s @Bindable:

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:
Source: ObservationBackport.md:19-26

WithPerceptionTracking

When using Perception on iOS 13-16, wrap your view body in WithPerceptionTracking:
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:
Source: ObservationBackport.md:62-81

Cross-Platform Pattern

For apps supporting both iOS 17+ and earlier versions:

Lazy View Closures

Many SwiftUI closures are lazy and execute after the body is computed. These require their own WithPerceptionTracking:
Fix by wrapping the lazy closure content:
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:
Source: ObservableState.swift:3-13

Identity Tracking

TCA tracks state identity to optimize view updates:
Source: ObservableState.swift:21-103

Store Observation

The Store type integrates with both Observation and Perception:
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:

Platform Differences

visionOS

On visionOS, TCA always uses native Observation:
Source: Store+Observation.swift:7-9

iOS 17+

On iOS 17 and later, Store conforms to Observable:
Source: Store.swift:574-579

Best Practices

Always Use @ObservableState

Even if you’re only supporting iOS 17+, use @ObservableState instead of @Observable:

Wrap All View Bodies (iOS 13-16)

Consistently use WithPerceptionTracking:

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:

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:

Debugging

Skip Perception Checking

For debugging, you can temporarily skip perception checking:
Source: Store.swift:167-174

Observation Registrar

The store uses an observation registrar internally:
Source: Store.swift:110-116