The Composable Architecture (TCA)

repository·main·Indexed 12 days ago

https://github.com/pointfreeco/swift-composable-architecture

A framework for building applications in Swift with a focus on state management, composition, side effects, and testing. Compatible with SwiftUI, UIKit, and other frameworks across all Apple platforms (iOS, macOS, iPadOS, visionOS, tvOS, and watchOS). It provides tools for breaking large features into smaller reusable modules and managing interactions with the outside world in a predictable, testable manner using @Reducer and @ObservableState.

Tokens
57.3K
Snippets
173
Records
247
Agent score
96%

What's inside TCA

  1. Overview of The Composable Architecture (TCA)

    main

    The Composable Architecture (TCA) is a library designed for building applications in a consistent, understandable, and composable way. It is compatible with SwiftUI, UIKit, and other frameworks across all Apple platforms (iOS, macOS, tvOS, and watchOS).

    TCA focuses on solving several core application development challenges:

    • State management: Managing application state using simple value types and sharing that state across multiple screens to ensure mutations are observed globally.
    • Composition: Breaking large features into smaller, isolated, and testable modules that can be composed back into a single feature.
    • Side effects: Providing a testable and understandable way for the application to interact with the outside world.
    • Testing: Enabling unit tests for individual features, integration tests for composed features, and end-to-end tests for side-effect influence.
    • Ergonomics: Providing a simple API with minimal concepts to reduce cognitive load.
  2. Overview of The Composable Architecture

    main

    The Composable Architecture (TCA) is a library designed for building applications in a consistent, understandable, and composable way. It is compatible with SwiftUI, UIKit, and other frameworks across all Apple platforms (iOS, macOS, iPadOS, visionOS, tvOS, and watchOS).

    Core capabilities include:

    • State Management: Managing application state using simple value types and sharing state across screens.
    • Composition: Breaking large features into smaller, isolated, and reusable modules.
    • Side Effects: Managing interactions with the outside world in a testable and predictable manner.
    • Testing: Providing tools for unit testing features, integration testing composed features, and end-to-end testing side effects.
    • Ergonomics: Offering a simple API with minimal moving parts.
  3. Use Store.case to simplify NavigationStack destinations

    main

    When using an enum-based @Reducer for a Path reducer in a NavigationStack, the macro adds a case computed property to the Store. This allows you to switch on the state and extract the scoped child store in a single step, avoiding the need to manually destructure the state and then use if let to unwrap a scoped store.

    NavigationStack(path: $store.scope(state: \.path, action: \.path)) {
      // Root view
    } destination: { store in
      switch store.case {
      case let .detail(store):
        DetailView(store: store)
      case let .meeting(store):
        MeetingView(store: store)
      case let .record(store):
        RecordView(store: store)
      }
    }
  4. Configure TestStore exhaustivity

    main

    By default, TestStore requires that every state change and every effect sent by the store is accounted for in your test assertions. This is known as exhaustivity. If an effect or state change occurs that you haven't explicitly asserted, the test will fail.

    You can control this behavior using the exhaustivity parameter when initializing a TestStore or by using the withExhaustivity methods to wrap specific operations.

    Use .complete (the default) to ensure all effects and state changes are accounted for, or use .none to disable exhaustivity checks for a specific test or operation, allowing effects to be ignored.

    // Example of disabling exhaustivity for a specific operation
    await store.withExhaustivity(.none) { 
      await store.send(.someAction) 
    }
  5. Robust testing patterns for @Shared state

    main

    To prevent bugs where a refactor accidentally removes @Shared from a feature (causing state synchronization to break), use one of these two patterns:

    1. Assert on all features: Even if state is shared, explicitly assert on the state of all features involved in the action.
    2. Capture and mutate in tests: Capture a reference to the @Shared state in your test function and mutate that reference inside the TestStore.send trailing closure. This ensures the test fails if the feature's state is no longer linked to that shared reference.

    To enforce the second pattern, you can make @Shared properties fileprivate within your feature's State struct.

    @Test
    func increment() async {
      @Shared(.appStorage("count")) var count = 0
      let store = TestStore(initialState: ParentFeature.State()) {
        ParentFeature()
      }
    
      await store.send(.feature1(.buttonTapped)) {
        // If feature1.count is no longer @Shared, this mutation will fail to sync
        count = 1
      }
    }
  6. Understand Dependency Management in TCA

    main

    Dependencies are types and functions that interact with systems outside of your control, such as API clients, UUID generators, Date initializers, or clocks.

    In The Composable Architecture, dependencies are managed using the swift-dependencies library. By controlling these dependencies, you can swap real implementations (like a live network client) with mock implementations in tests and Xcode previews. This allows you to control the execution context and provide stubbed data instead of making live side effects.

  7. Derive sub-parts of shared state

    main

    You can derive a smaller piece of shared state from a larger @Shared value using the projected value (the $ syntax). This allows child features to hold onto only the specific piece of data they need, rather than the entire parent state tree.

    Dot-chaining on projections

    If a parent has @Shared var signUpData: SignUpData, a child can receive just the phone number by passing $signUpData.phoneNumber. The child's state would then simply declare @Shared var phoneNumber: String.

    Working with Persistence

    When deriving state, the child feature does not need to know about the persistence strategy. If the parent uses .fileStorage, any changes made by the child to the derived @Shared property will automatically propagate to the parent and be persisted.

    Deriving from collections (IdentifiedArray)

    To derive shared state for a specific element in an IdentifiedArray, use the [id:] subscript on the @Shared collection. This returns an optional shared state, which can be unwrapped into a non-optional Shared<Element> using the Shared initializer.

    // Deriving a sub-property
    case .nextButtonTapped:
      state.path.append(
        PhoneNumberFeature.State(phoneNumber: state.$signUpData.phoneNumber)
      )
    
    // Deriving from a collection
    @Shared(.fileStorage(.todos)) var todos: IdentifiedArrayOf<Todo> = []
    
    guard let todo = Shared($todos[id: todoID]) else { return }
    // todo is now Shared<Todo>