Skip to content

Latest commit

 

History

History
1162 lines (931 loc) · 28.1 KB

File metadata and controls

1162 lines (931 loc) · 28.1 KB

The Composable Architecture (TCA) - Best Practices Guide

Version: 1.23.1 Package: pointfreeco/swift-composable-architecture Installation: Swift Package Manager Swift Version: 6.0


Table of Contents

  1. Swift 6 & TCA
  2. Core Concepts
  3. Feature Structure
  4. State Management Patterns
  5. Effects & Dependencies
  6. Navigation Patterns
  7. Testing Strategies
  8. Common Patterns for Ladder App
  9. Performance Optimization
  10. Anti-Patterns to Avoid

Swift 6 & TCA

Swift 6 Language Mode

This project uses Swift 6.0 with complete concurrency checking enabled. TCA v1.23.1 is fully compatible with Swift 6's strict concurrency model.

Data-Race Safety

Swift 6 enforces data-race safety at compile time through the Sendable protocol and strict concurrency checking. TCA's architecture naturally aligns with these requirements:

State Types are Sendable

@Reducer
struct WorkoutFeature {
    @ObservableState
    struct State: Equatable {  // Automatically Sendable (value type)
        var repCount: Int = 0
        var elapsedTime: TimeInterval = 0
        var isRunning: Bool = false
    }
}

Effect Closures are @Sendable

case .startTimer:
    return .run { send in  // Closure is @Sendable
        for await _ in self.clock.timer(interval: .milliseconds(10)) {
            await send(.timerTick)  // Safe cross-actor communication
        }
    }
    .cancellable(id: CancelID.timer)

Dependencies are Sendable

@Dependency(\.continuousClock) var clock  // ContinuousClock is Sendable
@Dependency(\.uuid) var uuid              // UUID generator is Sendable
@Dependency(\.date) var date              // Date provider is Sendable

Swift 6 Concurrency Best Practices

1. Use Structured Concurrency

// ✅ Good - Structured concurrency with cancellation
case .startWorkout:
    return .run { send in
        for await _ in self.clock.timer(interval: .seconds(1)) {
            await send(.timerTick)
        }
    }
    .cancellable(id: CancelID.timer)

// ❌ Bad - Unstructured Task without cancellation
case .startWorkout:
    return .run { send in
        Task {  // Unstructured - can't be cancelled properly
            while true {
                await send(.timerTick)
                try? await Task.sleep(for: .seconds(1))
            }
        }
        return .none
    }

2. Actor Isolation for Shared Mutable State

// If you need shared mutable state outside TCA (rare), use actors
actor WorkoutMetricsTracker {
    private var totalWorkouts: Int = 0
    private var totalReps: Int = 0

    func recordWorkout(reps: Int) {
        totalWorkouts += 1
        totalReps += reps
    }

    func getStats() -> (workouts: Int, reps: Int) {
        (totalWorkouts, totalReps)
    }
}

3. Sendable Conformance for Custom Dependencies

struct FeatsClient: Sendable {  // Mark custom dependencies as Sendable
    var fetchChallenges: @Sendable () async throws -> [Challenge]
    var submitWorkout: @Sendable (WorkoutSubmission) async throws -> Void
}

extension FeatsClient: DependencyKey {
    static let liveValue = FeatsClient(
        fetchChallenges: { /* network call */ },
        submitWorkout: { /* network call */ }
    )
}

4. Main Actor for UI Updates

// TCA views are automatically @MainActor
struct WorkoutView: View {
    let store: StoreOf<WorkoutFeature>

    var body: some View {  // Runs on main actor
        WithViewStore(self.store, observe: { $0 }) { viewStore in
            VStack {
                Text("Reps: \(viewStore.repCount)")  // Safe UI update
                Button("Increment") {
                    viewStore.send(.incrementTapped)  // Send from main actor
                }
            }
        }
    }
}

Migration Notes from Swift 5

No Breaking Changes TCA v1.23.1 works seamlessly with Swift 6. The following patterns require no changes:

  • @Reducer macro
  • @ObservableState properties
  • Effect closures (already @Sendable)
  • Dependency injection via @Dependency
  • TestStore patterns

Compiler Warnings to Address If upgrading from Swift 5, you may see warnings about:

  1. Non-Sendable types captured in closures - Make your custom types Sendable
  2. Main actor warnings - TCA handles this automatically for views
  3. Unstructured concurrency - Use .run effects instead of raw Task

Example Migration

// Swift 5 - May show warnings in Swift 6
struct CustomType {  // ⚠️ Not Sendable
    var data: [String]
}

// Swift 6 - Add Sendable conformance
struct CustomType: Sendable {  // ✅ Explicitly Sendable
    let data: [String]  // Use immutable properties when possible
}

Swift 6 Benefits for TCA Apps

  1. Compile-Time Data-Race Safety: Catch concurrency bugs at compile time, not runtime
  2. Better Autocomplete: Swift 6 improves IDE support for TCA's macros
  3. Performance: Optimizations for value types and async/await
  4. Future-Proof: Latest language features and ongoing improvements

Core Concepts

What is TCA?

The Composable Architecture is a library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind. It addresses five critical areas:

  1. State Management: Managing application state using simple value types
  2. Composition: Breaking large features into smaller, isolated, reusable components
  3. Side Effects: Handling external interactions in testable, understandable ways
  4. Testing: Writing integration tests with strong business logic guarantees
  5. Ergonomics: Accomplishing all above with minimal concepts and moving parts

Philosophy

  • Value types over reference types: State is modeled with structs (Equatable) to avoid "action at a distance"
  • Unidirectional data flow: Actions → Reducer → State changes → UI updates
  • Side effects separated from logic: Pure state transformations vs effectful operations
  • Testability first: All dependencies injectable, all effects controllable

Feature Structure

The @Reducer Macro

Every feature in TCA is defined with the @Reducer macro, which organizes three essential components:

@Reducer
struct FeatureName {
    // 1. State - What data does this feature need?
    @ObservableState
    struct State: Equatable {
        var count: Int = 0
        var isLoading: Bool = false
    }

    // 2. Action - What can happen in this feature?
    enum Action {
        case incrementButtonTapped
        case decrementButtonTapped
        case loadDataResponse(Result<String, Error>)
    }

    // 3. Body - How does state change in response to actions?
    var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
            case .incrementButtonTapped:
                state.count += 1
                return .none

            case .decrementButtonTapped:
                state.count -= 1
                return .none

            case .loadDataResponse(.success(let data)):
                state.isLoading = false
                return .none

            case .loadDataResponse(.failure(let error)):
                state.isLoading = false
                return .none
            }
        }
    }
}

@ObservableState

The @ObservableState macro integrates with Swift's observation framework for efficient SwiftUI updates:

@ObservableState
struct State: Equatable {
    var repCount: Int = 0
    var elapsedTime: TimeInterval = 0
    var isTimerRunning: Bool = false
}

Benefits:

  • Only views observing changed properties re-render
  • Automatic change tracking
  • Compatible with @Observable from Swift 5.9+

State Management Patterns

1. Flat State vs Nested State

Flat State (simple features):

@ObservableState
struct State: Equatable {
    var username: String = ""
    var password: String = ""
    var isLoading: Bool = false
}

Nested State (composed features):

@ObservableState
struct State: Equatable {
    var profile: ProfileFeature.State
    var settings: SettingsFeature.State
    var currentTab: Tab = .profile
}

2. Optional State for Navigation

Use optional state to drive navigation (sheets, alerts, fullscreen):

@ObservableState
struct State: Equatable {
    var count: Int = 0
    @Presents var alert: AlertState<Action.Alert>?
    @Presents var destination: Destination.State?
}

enum Action {
    case showAlertButtonTapped
    case alert(PresentationAction<Alert>)
    case destination(PresentationAction<Destination.Action>)

    enum Alert {
        case confirmTapped
    }
}

@Reducer
struct Destination {
    // Nested feature for sheet/fullscreen
}

3. Shared State

For state that needs to be shared across multiple features:

@Reducer
struct AppFeature {
    @ObservableState
    struct State {
        @Shared(.appStorage("userToken")) var token: String?
        var workout: WorkoutFeature.State
        var leaderboard: LeaderboardFeature.State
    }
}

Effects & Dependencies

Effect Basics

Effects represent interactions with the outside world. They return Effect<Action>:

case .loadData:
    return .run { send in
        let data = try await apiClient.fetchData()
        await send(.loadDataResponse(.success(data)))
    } catch: { error, send in
        await send(.loadDataResponse(.failure(error)))
    }

Dependency Injection

Declare dependencies using @Dependency:

@Reducer
struct WorkoutFeature {
    @Dependency(\.continuousClock) var clock
    @Dependency(\.apiClient) var apiClient
    @Dependency(\.uuid) var uuid

    var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
            case .startTimer:
                return .run { send in
                    for await _ in self.clock.timer(interval: .seconds(1)) {
                        await send(.tick)
                    }
                }
                .cancellable(id: CancelID.timer)
            }
        }
    }
}

Timer Effects (Modern Clock Pattern)

Recommended approach using ContinuousClock:

@Dependency(\.continuousClock) var clock

case .startWorkout:
    state.isTimerRunning = true
    state.startTime = Date()

    return .run { send in
        for await _ in self.clock.timer(interval: .milliseconds(10)) {
            await send(.timerTick)
        }
    }
    .cancellable(id: CancelID.workoutTimer, cancelInFlight: true)

case .stopWorkout:
    state.isTimerRunning = false
    return .cancel(id: CancelID.workoutTimer)

case .timerTick:
    state.elapsedTime += 0.01
    return .none

Benefits:

  • ContinuousClock continues running during sleep/wake
  • TestClock allows deterministic testing
  • Cancellable with unique IDs

Background Task Handling

For handling app lifecycle (backgrounding, foregrounding):

@Dependency(\.continuousClock) var clock

case .scenePhaseChanged(let phase):
    switch phase {
    case .background:
        state.backgroundTime = Date()
        return .none

    case .active:
        if let backgroundTime = state.backgroundTime {
            let elapsed = Date().timeIntervalSince(backgroundTime)
            state.elapsedTime += elapsed
        }
        return .none

    default:
        return .none
    }

Cancellation

Always cancel long-running effects when appropriate:

enum CancelID {
    case workoutTimer
    case networkRequest
}

// Cancel specific effect
return .cancel(id: CancelID.workoutTimer)

// Cancel all effects
return .cancel(ids: CancelID.allCases)

// Auto-cancel with cancelInFlight
return .run { ... }
    .cancellable(id: CancelID.networkRequest, cancelInFlight: true)

Navigation Patterns

1. Sheet/FullScreen Presentation

Use @Presents and PresentationAction:

@Reducer
struct ParentFeature {
    @ObservableState
    struct State {
        @Presents var detail: DetailFeature.State?
    }

    enum Action {
        case showDetailButtonTapped
        case detail(PresentationAction<DetailFeature.Action>)
    }

    var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
            case .showDetailButtonTapped:
                state.detail = DetailFeature.State()
                return .none

            case .detail(.presented(.dismissButtonTapped)):
                state.detail = nil
                return .none

            case .detail:
                return .none
            }
        }
        .ifLet(\.$detail, action: \.detail) {
            DetailFeature()
        }
    }
}

// In SwiftUI view
.sheet(item: $store.scope(state: \.detail, action: \.detail)) { store in
    DetailView(store: store)
}

2. Navigation Stack (iOS 16+)

Use StackState for push/pop navigation:

@ObservableState
struct State {
    var path = StackState<Path.State>()
}

@Reducer
struct Path {
    @ObservableState
    enum State {
        case detail(DetailFeature.State)
        case settings(SettingsFeature.State)
    }

    enum Action {
        case detail(DetailFeature.Action)
        case settings(SettingsFeature.Action)
    }

    var body: some Reducer<State, Action> {
        Scope(state: \.detail, action: \.detail) {
            DetailFeature()
        }
        Scope(state: \.settings, action: \.settings) {
            SettingsFeature()
        }
    }
}

// In SwiftUI view
NavigationStack(path: $store.scope(state: \.path, action: \.path)) {
    RootView(store: store)
} destination: { store in
    switch store.case {
    case .detail(let store):
        DetailView(store: store)
    case .settings(let store):
        SettingsView(store: store)
    }
}

3. Alerts

@ObservableState
struct State {
    @Presents var alert: AlertState<Action.Alert>?
}

enum Action {
    case deleteButtonTapped
    case alert(PresentationAction<Alert>)

    enum Alert {
        case confirmDelete
    }
}

case .deleteButtonTapped:
    state.alert = AlertState {
        TextState("Delete Workout?")
    } actions: {
        ButtonState(role: .destructive, action: .confirmDelete) {
            TextState("Delete")
        }
        ButtonState(role: .cancel) {
            TextState("Cancel")
        }
    }
    return .none

case .alert(.presented(.confirmDelete)):
    // Perform deletion
    return .none

Testing Strategies

TestStore Basics

@Test
func testIncrement() async {
    let store = TestStore(initialState: CounterFeature.State()) {
        CounterFeature()
    }

    await store.send(.incrementButtonTapped) {
        $0.count = 1
    }
}

Testing Effects with Dependencies

@Test
func testTimer() async {
    let clock = TestClock()

    let store = TestStore(initialState: WorkoutFeature.State()) {
        WorkoutFeature()
    } withDependencies: {
        $0.continuousClock = clock
    }

    await store.send(.startWorkout) {
        $0.isTimerRunning = true
        $0.elapsedTime = 0
    }

    await clock.advance(by: .seconds(1))
    await store.receive(\.timerTick) {
        $0.elapsedTime = 1.0
    }

    await store.send(.stopWorkout) {
        $0.isTimerRunning = false
    }
}

Testing Navigation

@Test
func testSheetPresentation() async {
    let store = TestStore(initialState: ParentFeature.State()) {
        ParentFeature()
    }

    await store.send(.showDetailButtonTapped) {
        $0.detail = DetailFeature.State()
    }

    await store.send(.detail(.presented(.dismissButtonTapped))) {
        $0.detail = nil
    }
}

Exhaustive Testing

TCA enforces exhaustive testing - you MUST assert:

  • All state changes
  • All received actions from effects
  • Effects are properly canceled
// This will fail if you don't assert the state change
await store.send(.incrementButtonTapped)
// ❌ Error: State was not asserted

// Correct
await store.send(.incrementButtonTapped) {
    $0.count = 1
}
// ✅ Passes

Common Patterns for Ladder App

1. Workout Timer Feature

@Reducer
struct WorkoutTimerFeature {
    @ObservableState
    struct State: Equatable {
        var repCount: Int = 0
        var elapsedTime: TimeInterval = 0
        var isRunning: Bool = false
        var startTime: Date?
        var backgroundTime: Date?
    }

    enum Action {
        case startButtonTapped
        case stopButtonTapped
        case incrementRepTapped
        case decrementRepTapped
        case timerTick
        case scenePhaseChanged(ScenePhase)
    }

    @Dependency(\.continuousClock) var clock
    @Dependency(\.date.now) var now

    enum CancelID { case timer }

    var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
            case .startButtonTapped:
                state.isRunning = true
                state.startTime = now

                return .run { send in
                    for await _ in self.clock.timer(interval: .milliseconds(10)) {
                        await send(.timerTick)
                    }
                }
                .cancellable(id: CancelID.timer, cancelInFlight: true)

            case .stopButtonTapped:
                state.isRunning = false
                return .cancel(id: CancelID.timer)

            case .incrementRepTapped:
                state.repCount += 1
                return .none

            case .decrementRepTapped:
                state.repCount = max(0, state.repCount - 1)
                return .none

            case .timerTick:
                state.elapsedTime += 0.01
                return .none

            case .scenePhaseChanged(.background):
                state.backgroundTime = now
                return .none

            case .scenePhaseChanged(.active):
                if let backgroundTime = state.backgroundTime,
                   let startTime = state.startTime {
                    let elapsed = now.timeIntervalSince(backgroundTime)
                    state.elapsedTime += elapsed
                }
                state.backgroundTime = nil
                return .none

            case .scenePhaseChanged:
                return .none
            }
        }
    }
}

2. Leaderboard with Pagination

@Reducer
struct LeaderboardFeature {
    @ObservableState
    struct State: Equatable {
        var entries: IdentifiedArrayOf<LeaderboardEntry> = []
        var isLoading: Bool = false
        var hasMorePages: Bool = true
        var currentPage: Int = 0
        var filter: TimeFilter = .allTime

        enum TimeFilter: String, CaseIterable {
            case thisWeek = "This Week"
            case allTime = "All Time"
        }
    }

    enum Action {
        case onAppear
        case loadNextPage
        case filterChanged(State.TimeFilter)
        case loadResponse(Result<[LeaderboardEntry], Error>)
        case pullToRefresh
    }

    @Dependency(\.apiClient) var apiClient

    var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
            case .onAppear:
                guard state.entries.isEmpty else { return .none }
                return .send(.loadNextPage)

            case .loadNextPage:
                guard !state.isLoading, state.hasMorePages else {
                    return .none
                }

                state.isLoading = true
                let page = state.currentPage
                let filter = state.filter

                return .run { send in
                    let entries = try await apiClient.fetchLeaderboard(
                        page: page,
                        filter: filter
                    )
                    await send(.loadResponse(.success(entries)))
                }

            case .filterChanged(let filter):
                state.filter = filter
                state.entries = []
                state.currentPage = 0
                state.hasMorePages = true
                return .send(.loadNextPage)

            case .loadResponse(.success(let entries)):
                state.isLoading = false
                state.entries.append(contentsOf: entries)
                state.currentPage += 1
                state.hasMorePages = entries.count >= 20
                return .none

            case .loadResponse(.failure):
                state.isLoading = false
                return .none

            case .pullToRefresh:
                state.entries = []
                state.currentPage = 0
                state.hasMorePages = true
                return .send(.loadNextPage)
            }
        }
    }
}

3. Challenge Detail with Video

@Reducer
struct ChallengeDetailFeature {
    @ObservableState
    struct State: Equatable {
        var challenge: Challenge
        var isVideoPlaying: Bool = false
        var participantCount: Int
        @Presents var activeWorkout: WorkoutTimerFeature.State?
    }

    enum Action {
        case startTestButtonTapped
        case playVideoButtonTapped
        case videoPlaybackChanged(Bool)
        case activeWorkout(PresentationAction<WorkoutTimerFeature.Action>)
        case workoutCompleted(repCount: Int, duration: TimeInterval)
    }

    @Dependency(\.apiClient) var apiClient

    var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
            case .startTestButtonTapped:
                state.activeWorkout = WorkoutTimerFeature.State()
                return .none

            case .playVideoButtonTapped:
                state.isVideoPlaying.toggle()
                return .none

            case .videoPlaybackChanged(let isPlaying):
                state.isVideoPlaying = isPlaying
                return .none

            case .activeWorkout(.presented(.stopButtonTapped)):
                if let workout = state.activeWorkout {
                    return .send(.workoutCompleted(
                        repCount: workout.repCount,
                        duration: workout.elapsedTime
                    ))
                }
                return .none

            case .workoutCompleted(let repCount, let duration):
                state.activeWorkout = nil

                return .run { [id = state.challenge.id] send in
                    try await apiClient.submitWorkout(
                        challengeID: id,
                        repCount: repCount,
                        duration: duration
                    )
                }

            case .activeWorkout:
                return .none
            }
        }
        .ifLet(\.$activeWorkout, action: \.activeWorkout) {
            WorkoutTimerFeature()
        }
    }
}

4. Root App Feature with Tab Navigation

@Reducer
struct AppFeature {
    @ObservableState
    struct State {
        var selectedTab: Tab = .workouts
        var workouts: WorkoutsFeature.State
        var profile: ProfileFeature.State

        enum Tab {
            case workouts, leaderboard, profile
        }
    }

    enum Action {
        case selectedTabChanged(State.Tab)
        case workouts(WorkoutsFeature.Action)
        case profile(ProfileFeature.Action)
    }

    var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
            case .selectedTabChanged(let tab):
                state.selectedTab = tab
                return .none

            default:
                return .none
            }
        }

        Scope(state: \.workouts, action: \.workouts) {
            WorkoutsFeature()
        }

        Scope(state: \.profile, action: \.profile) {
            ProfileFeature()
        }
    }
}

Performance Optimization

1. Use @ObservableState Efficiently

// ✅ Good - granular observations
@ObservableState
struct State {
    var count: Int
    var name: String
}

// Views only re-render when observed properties change

2. Avoid Computing in State

// ❌ Bad - computed properties aren't observable
@ObservableState
struct State {
    var items: [Item]
    var filteredItems: [Item] {
        items.filter { $0.isActive }
    }
}

// ✅ Good - store computed values
@ObservableState
struct State {
    var items: [Item]
    var filteredItems: [Item] = []
}

// Update in reducer
case .itemsChanged:
    state.filteredItems = state.items.filter { $0.isActive }

3. Scope Properly

// ✅ Good - narrow scope
struct ChildView: View {
    let store: StoreOf<ChildFeature>

    var body: some View {
        WithViewStore(store, observe: { $0 }) { viewStore in
            // Only observes ChildFeature.State
        }
    }
}

4. Use IdentifiedArray for Lists

// ✅ Efficient diffing and lookup
@ObservableState
struct State {
    var leaderboardEntries: IdentifiedArrayOf<LeaderboardEntry> = []
}

struct LeaderboardEntry: Identifiable {
    let id: UUID
    var rank: Int
    var name: String
}

Anti-Patterns to Avoid

1. ❌ Mutating State Outside Reducer

// ❌ NEVER DO THIS
class BadViewModel: ObservableObject {
    @Published var state: Feature.State

    func updateCount() {
        state.count += 1 // Direct mutation!
    }
}

// ✅ Always use actions
store.send(.incrementButtonTapped)

2. ❌ Side Effects in Reducer Logic

// ❌ Bad - side effects in reduce
case .saveButtonTapped:
    try await apiClient.save(state.data) // Don't do this!
    return .none

// ✅ Good - return effect
case .saveButtonTapped:
    return .run { [data = state.data] send in
        try await apiClient.save(data)
        await send(.saveResponse(.success))
    }

3. ❌ Not Canceling Long-Running Effects

// ❌ Bad - timer never stops
case .startTimer:
    return .run { send in
        for await _ in clock.timer(interval: .seconds(1)) {
            await send(.tick)
        }
    }

// ✅ Good - cancellable timer
case .startTimer:
    return .run { send in
        for await _ in clock.timer(interval: .seconds(1)) {
            await send(.tick)
        }
    }
    .cancellable(id: CancelID.timer)

case .stopTimer:
    return .cancel(id: CancelID.timer)

4. ❌ Using Reference Types in State

// ❌ Bad - reference type causes issues
@ObservableState
struct State {
    var viewModel: SomeViewModel // Class reference!
}

// ✅ Good - value types only
@ObservableState
struct State {
    var data: DataModel // Struct
    var items: [Item] // Value type
}

5. ❌ Not Testing Exhaustively

// ❌ Bad - missing state assertions
await store.send(.incrementButtonTapped)
// Test passes but doesn't verify anything!

// ✅ Good - exhaustive testing
await store.send(.incrementButtonTapped) {
    $0.count = 1
}

Package Installation

Swift Package Manager

Add to your Package.swift:

dependencies: [
    .package(
        url: "https://github.com/pointfreeco/swift-composable-architecture",
        exact: "1.23.1"
    )
]

Or in Xcode:

  1. File → Add Package Dependencies
  2. Enter: https://github.com/pointfreeco/swift-composable-architecture
  3. Select version: 1.23.1

Additional Resources


Summary: Key Takeaways for Ladder

  1. Use @Reducer and @ObservableState macros for all features
  2. Model navigation with optional state and @Presents
  3. Use ContinuousClock for timer effects (testable with TestClock)
  4. Inject all dependencies via @Dependency for testability
  5. Always cancel long-running effects when appropriate
  6. Test exhaustively - TCA enforces correctness
  7. Keep state as value types - no classes, no reference semantics
  8. Separate effects from logic - reducers are pure functions

TCA provides the structure and discipline needed for a complex app like Ladder, especially with features like real-time workout tracking, leaderboards, and navigation flows.