Version: 1.23.1 Package: pointfreeco/swift-composable-architecture Installation: Swift Package Manager Swift Version: 6.0
- Swift 6 & TCA
- Core Concepts
- Feature Structure
- State Management Patterns
- Effects & Dependencies
- Navigation Patterns
- Testing Strategies
- Common Patterns for Ladder App
- Performance Optimization
- Anti-Patterns to Avoid
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.
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 Sendable1. 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
}
}
}
}
}No Breaking Changes TCA v1.23.1 works seamlessly with Swift 6. The following patterns require no changes:
@Reducermacro@ObservableStateproperties- Effect closures (already
@Sendable) - Dependency injection via
@Dependency - TestStore patterns
Compiler Warnings to Address If upgrading from Swift 5, you may see warnings about:
- Non-Sendable types captured in closures - Make your custom types
Sendable - Main actor warnings - TCA handles this automatically for views
- Unstructured concurrency - Use
.runeffects instead of rawTask
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
}- Compile-Time Data-Race Safety: Catch concurrency bugs at compile time, not runtime
- Better Autocomplete: Swift 6 improves IDE support for TCA's macros
- Performance: Optimizations for value types and async/await
- Future-Proof: Latest language features and ongoing improvements
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:
- State Management: Managing application state using simple value types
- Composition: Breaking large features into smaller, isolated, reusable components
- Side Effects: Handling external interactions in testable, understandable ways
- Testing: Writing integration tests with strong business logic guarantees
- Ergonomics: Accomplishing all above with minimal concepts and moving parts
- 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
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
}
}
}
}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
@Observablefrom Swift 5.9+
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
}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
}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 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)))
}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)
}
}
}
}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 .noneBenefits:
ContinuousClockcontinues running during sleep/wakeTestClockallows deterministic testing- Cancellable with unique IDs
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
}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)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)
}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)
}
}@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@Test
func testIncrement() async {
let store = TestStore(initialState: CounterFeature.State()) {
CounterFeature()
}
await store.send(.incrementButtonTapped) {
$0.count = 1
}
}@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
}
}@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
}
}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@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
}
}
}
}@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)
}
}
}
}@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()
}
}
}@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()
}
}
}// ✅ Good - granular observations
@ObservableState
struct State {
var count: Int
var name: String
}
// Views only re-render when observed properties change// ❌ 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 }// ✅ Good - narrow scope
struct ChildView: View {
let store: StoreOf<ChildFeature>
var body: some View {
WithViewStore(store, observe: { $0 }) { viewStore in
// Only observes ChildFeature.State
}
}
}// ✅ Efficient diffing and lookup
@ObservableState
struct State {
var leaderboardEntries: IdentifiedArrayOf<LeaderboardEntry> = []
}
struct LeaderboardEntry: Identifiable {
let id: UUID
var rank: Int
var name: String
}// ❌ NEVER DO THIS
class BadViewModel: ObservableObject {
@Published var state: Feature.State
func updateCount() {
state.count += 1 // Direct mutation!
}
}
// ✅ Always use actions
store.send(.incrementButtonTapped)// ❌ 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))
}// ❌ 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)// ❌ 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
}// ❌ 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
}Add to your Package.swift:
dependencies: [
.package(
url: "https://github.com/pointfreeco/swift-composable-architecture",
exact: "1.23.1"
)
]Or in Xcode:
- File → Add Package Dependencies
- Enter:
https://github.com/pointfreeco/swift-composable-architecture - Select version:
1.23.1
- Official Docs: https://pointfreeco.github.io/swift-composable-architecture/
- Point-Free Episodes: https://www.pointfree.co/collections/composable-architecture
- GitHub Discussions: https://github.com/pointfreeco/swift-composable-architecture/discussions
- Migration Guides: Available in the package documentation
- Use @Reducer and @ObservableState macros for all features
- Model navigation with optional state and
@Presents - Use ContinuousClock for timer effects (testable with TestClock)
- Inject all dependencies via
@Dependencyfor testability - Always cancel long-running effects when appropriate
- Test exhaustively - TCA enforces correctness
- Keep state as value types - no classes, no reference semantics
- 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.