composable-architecture
Use when building features with TCA (The Composable Architecture), structuring reducers, managing state, handling effects, navigation, or testing TCA features. Covers @Reducer, Store, Effect, TestStore, reducer composition, and TCA patterns.
Packaged view
This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.
Install command
npx @skill-hub/cli install johnrogers-claude-swift-engineering-composable-architecture
Repository
Skill path: plugins/swift-engineering/skills/composable-architecture
Use when building features with TCA (The Composable Architecture), structuring reducers, managing state, handling effects, navigation, or testing TCA features. Covers @Reducer, Store, Effect, TestStore, reducer composition, and TCA patterns.
Open repositoryBest for
Primary workflow: Ship Full Stack.
Technical facets: Full Stack, Testing.
Target audience: everyone.
License: Unknown.
Original source
Catalog source: SkillHub Club.
Repository owner: johnrogers.
This is still a mirrored public skill entry. Review the repository before installing into production workflows.
What it helps with
- Install composable-architecture into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
- Review https://github.com/johnrogers/claude-swift-engineering before adding composable-architecture to shared team environments
- Use composable-architecture for development workflows
Works across
Favorites: 0.
Sub-skills: 0.
Aggregator: No.
Original source / Raw SKILL.md
---
name: composable-architecture
description: Use when building features with TCA (The Composable Architecture), structuring reducers, managing state, handling effects, navigation, or testing TCA features. Covers @Reducer, Store, Effect, TestStore, reducer composition, and TCA patterns.
---
# The Composable Architecture (TCA)
TCA provides architecture for building complex, testable features through composable reducers, centralized state management, and side effect handling. The core principle: predictable state evolution with clear dependencies and testable effects.
## Reference Loading Guide
**ALWAYS load reference files if there is even a small chance the content may be required.** It's better to have the context than to miss a pattern or make a mistake.
| Reference | Load When |
|-----------|-----------|
| **[Reducer Structure](references/reducer-structure.md)** | Creating new reducers, setting up `@Reducer`, `State`, `Action`, or `@ViewAction` |
| **[Views - Binding](references/views-binding.md)** | Using `@Bindable`, two-way bindings, `store.send()`, or `.onAppear`/`.task` |
| **[Views - Composition](references/views-composition.md)** | Using `ForEach` with stores, scoping to child features, or optional children |
| **[Navigation - Basics](references/navigation-basics.md)** | Setting up `NavigationStack`, path reducers, pushing/popping, or programmatic dismiss |
| **[Navigation - Advanced](references/navigation-advanced.md)** | Deep linking, recursive navigation, or combining NavigationStack with sheets |
| **[Shared State](references/shared-state.md)** | Using `@Shared`, `.appStorage`, `.withLock`, or sharing state between features |
| **[Dependencies](references/dependencies.md)** | Creating `@DependencyClient`, using `@Dependency`, or setting up test dependencies |
| **[Effects](references/effects.md)** | Using `.run`, `.send`, `.merge`, timers, effect cancellation, or async work |
| **[Presentation](references/presentation.md)** | Using `@Presents`, `AlertState`, sheets, popovers, or the Destination pattern |
| **[Testing - Fundamentals](references/testing-fundamentals.md)** | Setting up test suites, `makeStore` helpers, or understanding Equatable requirements |
| **[Testing - Patterns](references/testing-patterns.md)** | Testing actions, state changes, dependencies, errors, or presentations |
| **[Testing - Advanced](references/testing-advanced.md)** | Using `TestClock`, keypath matching, `exhaustivity = .off`, or time-based tests |
| **[Testing - Utilities](references/testing-utilities.md)** | Test data factories, `LockIsolated`, `ConfirmationDialogState` testing, or `@Shared` testing |
| **[Performance](references/performance.md)** | Optimizing state updates, high-frequency actions, memory, or store scoping |
## Common Mistakes
1. **Over-modularizing features** — Breaking features into too many small reducers makes state management harder and adds composition overhead. Keep related state and actions together unless there's genuine reuse.
2. **Mismanaging effect lifetimes** — Forgetting to cancel effects when state changes leads to stale data, duplicate requests, or race conditions. Use `.concatenate` for sequential effects and `.cancel` when appropriate.
3. **Navigation state in wrong places** — Putting navigation state in child reducers instead of parent causes unnecessary view reloads and state inconsistencies. Navigation state belongs in the feature that owns the navigation structure.
4. **Testing without TestStore exhaustivity** — Skipping TestStore assertions for "simple" effects or "obvious" state changes means you miss bugs. Use exhaustivity checking religiously; it catches regressions early.
5. **Mixing async/await with Effects incorrectly** — Converting async/await to `.run` effects without proper cancellation or error handling loses isolation guarantees. Wrap async operations carefully in `.run` with `yield` statements.
---
## Referenced Files
> The following files are referenced in this skill and included for context.
### references/reducer-structure.md
```markdown
# Reducer Structure, Actions, and State
Detailed patterns for structuring TCA reducers, organizing actions, and defining state.
## Reducer Structure
### Basic Reducer Template
```swift
@Reducer
public struct FeatureNameReducer {
@ObservableState
public struct State: Equatable {
// State properties
public init() {}
}
public enum Action: ViewAction {
// Actions that are called from this reducer's view, and this reducer's view only.
enum View {
case onAppear
}
case view(View)
// Actions that this reducer can use to delegate to other reducers.
case delegate(Delegate)
// Actions that can be triggered from other reducers.
case interface(Interface)
// Internal actions
}
public init() {}
@Dependency(\.dependencyName) var dependencyName
public var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .view(let viewAction):
switch viewAction {
case .onAppear:
return .send(.loadData)
case .didTapSave:
return .send(.saveData)
}
case .delegate:
return .none
case .interface:
return .none
}
}
.ifLet(\.childState, action: \.childAction) {
ChildReducer()
}
.ifLet(\.$destination, action: \.destination)
}
}
```
### @Reducer Enum Conformances
**CRITICAL**: `@Reducer` enum definitions must use extensions for protocol conformances like `Equatable` or `Sendable`. Never add conformances directly to the `@Reducer` declaration.
```swift
// ❌ INCORRECT - Do not add conformances directly
@Reducer enum Destination: Equatable {
case settings(SettingsFeature)
}
// ✅ CORRECT - Use extension for conformances
@Reducer enum Destination {
case settings(SettingsFeature)
}
extension Destination: Equatable {}
```
**Pattern**: Always define the extension at file scope, directly after the parent reducer's closing brace:
```swift
@Reducer
struct ParentFeature {
@ObservableState
struct State: Equatable {
@Presents var destination: Destination.State?
}
enum Action {
case destination(PresentationAction<Destination.Action>)
}
@Reducer enum Destination {
case settings(SettingsFeature)
case detail(DetailFeature)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
// ...
}
.ifLet(\.$destination, action: \.destination)
}
}
// Extension must be at file scope, after the reducer definition
extension ParentFeature.Destination: Equatable {}
```
**Why this is required**: The `@Reducer` macro generates code that conflicts with conformances added directly to the enum declaration. Extensions allow the macro-generated code to work correctly while still providing the necessary protocol conformances.
## Action Organization
Always organize actions by their intended use:
```swift
public enum Action: ViewAction {
// MARK: - View Actions
enum View {
case onAppear
case didTapSave
case didTapCancel
case didSelectItem(Int)
case didChangeText(String)
}
case view(View)
// MARK: - Delegate Actions
enum Delegate: Equatable {
case userDidCompleteFlow
case onDataLoaded(Data)
case onError(Error)
}
case delegate(Delegate)
// MARK: - Interface Actions
enum Interface: Equatable {
case refresh
case reload
case updateData(Data)
}
case interface(Interface)
// MARK: - Internal Actions
case loadData
case didLoadData(Result<Data, Error>)
case saveData
case didSaveData(Result<Void, Error>)
case setAlertState(AlertState<Action.Alert>)
case setDestination(Destination.State?)
// MARK: - Presentation Actions
case destination(PresentationAction<Destination.Action>)
case alert(PresentationAction<Action.Alert>)
}
```
## Result Types in Actions
Use `Result` types for async operation responses to handle both success and failure cases:
```swift
enum Action {
case numberFactButtonTapped
case numberFactResponse(Result<String, any Error>)
case loadUserButtonTapped
case userResponse(Result<User, any Error>)
}
```
Handle in reducer:
```swift
case .numberFactButtonTapped:
state.isLoading = true
return .run { [count = state.count] send in
await send(.numberFactResponse(Result {
try await factClient.fetch(count)
}))
}
case .numberFactResponse(.success(let fact)):
state.isLoading = false
state.fact = fact
return .none
case .numberFactResponse(.failure(let error)):
state.isLoading = false
state.alert = AlertState {
TextState("Error loading fact: \(error.localizedDescription)")
}
return .none
```
### Result with catch:
Alternatively, use the `catch:` parameter in effects:
```swift
case .loadItem(let id):
return .run { send in
let item = try await apiClient.fetchItem(id)
await send(.itemLoaded(item))
} catch: { error, send in
await send(.loadFailed(error))
}
```
## State Management
### Observable State
```swift
@ObservableState
public struct State: Equatable {
// Basic properties
var isLoading: Bool = false
var data: [Item] = []
var selectedItem: Item?
// Shared state
@Shared var userPreferences: UserPreferences
// Presentation state
@Presents var destination: Destination.State?
@Presents var alert: AlertState<Action.Alert>?
// Computed properties
var isEmpty: Bool {
data.isEmpty
}
var canSave: Bool {
!data.isEmpty && !isLoading
}
}
```
### Complex State with CasePathable
```swift
@ObservableState
public struct State: Equatable {
@CasePathable
@dynamicMemberLookup
enum LoadingState: Equatable {
case idle
case loading
case loaded(Data)
case error(Error)
}
var loadingState: LoadingState = .idle
var otherProperties: String = ""
}
```
```
### references/views-binding.md
```markdown
# View Binding Patterns
Basic store-driven views and @Bindable patterns for two-way bindings.
## Basic Store-Driven View
```swift
struct CounterView: View {
let store: StoreOf<Counter>
var body: some View {
HStack {
Button {
store.send(.decrementButtonTapped)
} label: {
Image(systemName: "minus")
}
Text("\(store.count)")
.monospacedDigit()
Button {
store.send(.incrementButtonTapped)
} label: {
Image(systemName: "plus")
}
}
}
}
```
## @Bindable for Two-Way Bindings
Use `@Bindable` to enable SwiftUI controls to bind directly to store state:
```swift
struct BindingFormView: View {
@Bindable var store: StoreOf<BindingForm>
var body: some View {
Form {
TextField("Type here", text: $store.text)
Toggle("Disable other controls", isOn: $store.toggleIsOn)
Stepper(
"Max slider value: \(store.stepCount)",
value: $store.stepCount,
in: 0...100
)
Slider(value: $store.sliderValue, in: 0...Double(store.stepCount))
}
}
}
```
### @Bindable with Actions
For actions that need custom logic on value changes:
```swift
struct SettingsView: View {
@Bindable var store: StoreOf<Settings>
var body: some View {
Toggle(
"Notifications",
isOn: $store.notificationsEnabled.sending(\.toggleNotifications)
)
Stepper(
"\(store.count)",
value: $store.count.sending(\.stepperChanged)
)
}
}
```
Corresponding reducer:
```swift
enum Action: BindableAction {
case binding(BindingAction<State>)
case toggleNotifications
case stepperChanged
var body: some ReducerOf<Self> {
BindingReducer()
Reduce { state, action in
switch action {
case .toggleNotifications:
// Custom logic when toggle changes
return .send(.requestNotificationPermission)
case .stepperChanged:
// Custom logic when stepper changes
return .send(.trackCountChange)
case .binding:
return .none
}
}
}
}
```
## Observing State Changes
### Direct State Access
```swift
struct StatusView: View {
let store: StoreOf<Status>
var body: some View {
VStack {
if store.isLoading {
ProgressView()
} else if let error = store.error {
ErrorView(error: error)
} else {
ContentView(data: store.data)
}
}
}
}
```
### State-Driven Animations
```swift
struct AnimatedCounterView: View {
let store: StoreOf<Counter>
var body: some View {
Text("\(store.count)")
.font(.largeTitle)
.animation(.spring(), value: store.count)
}
}
```
## View Actions
### onAppear Pattern
```swift
struct FeatureView: View {
let store: StoreOf<Feature>
var body: some View {
VStack {
// Content
}
.onAppear {
store.send(.view(.onAppear))
}
}
}
```
### task Pattern for View Lifetime
```swift
struct FeatureView: View {
let store: StoreOf<Feature>
var body: some View {
VStack {
// Content
}
.task {
await store.send(.view(.runTasks)).finish()
}
.onAppear {
store.send(.view(.onAppear))
}
}
}
```
The `.task` modifier automatically cancels the effect when the view disappears, making it ideal for streaming effects that should run for the view's lifetime.
## Best Practices
1. **Use `let store`** - Store should be immutable in view
2. **Use `@Bindable`** - For two-way bindings with SwiftUI controls
3. **Actions for user events** - Send `.view` actions for user interactions
4. **`.task` for lifetime effects** - Use for streaming effects that should auto-cancel
5. **`.onAppear` for one-time work** - Use for initial data loading
```
### references/views-composition.md
```markdown
# View Composition Patterns
ForEach scoping, child features, and optional child views.
## ForEach with Scoped Stores
### IdentifiedArray Pattern
```swift
struct TodosView: View {
let store: StoreOf<Todos>
var body: some View {
List {
ForEach(
store.scope(state: \.todos, action: \.todos)
) { store in
TodoRowView(store: store)
}
}
}
}
```
Corresponding reducer:
```swift
@Reducer
struct Todos {
@ObservableState
struct State: Equatable {
var todos: IdentifiedArrayOf<Todo.State> = []
}
enum Action {
case todos(IdentifiedActionOf<Todo>)
}
var body: some Reducer<State, Action> {
Reduce { state, action in
// Parent-level logic
return .none
}
.forEach(\.todos, action: \.todos) {
Todo()
}
}
}
```
### Filtered Collections
```swift
struct TodosView: View {
let store: StoreOf<Todos>
var body: some View {
List {
ForEach(
store.scope(state: \.filteredTodos, action: \.todos)
) { store in
TodoRowView(store: store)
}
}
}
}
```
Corresponding state with computed property:
```swift
@ObservableState
struct State: Equatable {
var todos: IdentifiedArrayOf<Todo.State> = []
var filter: Filter = .all
var filteredTodos: IdentifiedArrayOf<Todo.State> {
switch filter {
case .all:
return todos
case .active:
return todos.filter { !$0.isComplete }
case .completed:
return todos.filter { $0.isComplete }
}
}
}
```
## Child Feature Scope
### Single Child Feature
```swift
struct TwoCountersView: View {
let store: StoreOf<TwoCounters>
var body: some View {
VStack {
CounterView(
store: store.scope(state: \.counter1, action: \.counter1)
)
CounterView(
store: store.scope(state: \.counter2, action: \.counter2)
)
}
}
}
```
Corresponding reducer:
```swift
@Reducer
struct TwoCounters {
@ObservableState
struct State: Equatable {
var counter1 = Counter.State()
var counter2 = Counter.State()
}
enum Action {
case counter1(Counter.Action)
case counter2(Counter.Action)
}
var body: some Reducer<State, Action> {
Scope(state: \.counter1, action: \.counter1) {
Counter()
}
Scope(state: \.counter2, action: \.counter2) {
Counter()
}
}
}
```
## Optional Child Features
### Using ifLet
```swift
struct OptionalCounterView: View {
let store: StoreOf<OptionalCounter>
var body: some View {
VStack {
if let store = store.scope(state: \.counter, action: \.counter) {
CounterView(store: store)
} else {
Text("Counter not loaded")
}
Button("Toggle Counter") {
store.send(.toggleCounterButtonTapped)
}
}
}
}
```
Corresponding reducer:
```swift
@Reducer
struct OptionalCounter {
@ObservableState
struct State: Equatable {
var counter: Counter.State?
}
enum Action {
case counter(Counter.Action)
case toggleCounterButtonTapped
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .toggleCounterButtonTapped:
state.counter = state.counter == nil ? Counter.State() : nil
return .none
case .counter:
return .none
}
}
.ifLet(\.counter, action: \.counter) {
Counter()
}
}
}
```
## Best Practices
1. **Scope stores** - Use `store.scope(state:action:)` for child features
2. **Computed properties** - Filter/transform collections in state, not view
3. **IdentifiedArrayOf** - Use for collections of child features
4. **`.forEach`** - Compose reducers for collections
5. **`.ifLet`** - Compose reducers for optional child features
6. **Scope in view** - Create scoped stores in the view body for proper observation
```
### references/navigation-basics.md
```markdown
# Navigation Basics
NavigationStack patterns with path reducers and programmatic navigation.
## NavigationStack with Path Reducer
### Basic Pattern
```swift
@Reducer
struct NavigationDemo {
@Reducer
enum Path {
case screenA(ScreenA)
case screenB(ScreenB)
case screenC(ScreenC)
}
@ObservableState
struct State: Equatable {
var path = StackState<Path.State>()
}
enum Action {
case path(StackActionOf<Path>)
case popToRoot
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .popToRoot:
state.path.removeAll()
return .none
case .path:
return .none
}
}
.forEach(\.path, action: \.path)
}
}
```
### View with store.case Pattern
```swift
struct NavigationDemoView: View {
@Bindable var store: StoreOf<NavigationDemo>
var body: some View {
NavigationStack(
path: $store.scope(state: \.path, action: \.path)
) {
RootView()
} destination: { store in
switch store.case {
case let .screenA(store):
ScreenAView(store: store)
case let .screenB(store):
ScreenBView(store: store)
case let .screenC(store):
ScreenCView(store: store)
}
}
}
}
```
## Navigation Actions
### Pushing to Stack
```swift
case .view(.didTapNavigateToDetail):
state.path.append(.detail(Detail.State()))
return .none
case .view(.didTapNavigateToSettings):
state.path.append(.settings(Settings.State(id: state.selectedId)))
return .none
```
### Popping from Stack
```swift
// Pop one screen
case .view(.didTapBack):
state.path.removeLast()
return .none
// Pop to root
case .view(.didTapPopToRoot):
state.path.removeAll()
return .none
// Pop to specific index
case .view(.didTapPopToFirst):
state.path.removeAll(after: 0)
return .none
```
### Programmatic Dismiss
Use `@Dependency(\.dismiss)` for child features to dismiss themselves:
```swift
@Reducer
struct DetailFeature {
@Dependency(\.dismiss) var dismiss
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .view(.didTapClose):
return .run { _ in
await self.dismiss()
}
case .view(.didSave):
return .concatenate(
.send(.delegate(.didSave)),
.run { _ in await self.dismiss() }
)
}
}
}
}
```
## Handling Child Actions
### Responding to Delegate Actions
```swift
case .path(.element(id: _, action: .detail(.delegate(.didSave)))):
// Detail screen saved, pop it
state.path.removeLast()
return .send(.refreshData)
case .path(.element(id: _, action: .settings(.delegate(.didLogout)))):
// Settings logged out, pop to root
state.path.removeAll()
return .send(.delegate(.userDidLogout))
```
### Inspecting Navigation Stack
```swift
case .view(.didTapSave):
// Check if we're in a specific screen
guard state.path.last(where: { $0.is(\.detail) }) != nil else {
return .none
}
return .send(.path(.element(id: state.path.ids.last!, action: .detail(.save))))
```
## Enum Reducer Conformances
**CRITICAL**: When using `@Reducer enum Path`, add protocol conformances via extension:
```swift
@Reducer
struct NavigationDemo {
@Reducer
enum Path {
case screenA(ScreenA)
case screenB(ScreenB)
}
}
// Extension must be at file scope
extension NavigationDemo.Path: Equatable {}
```
## Best Practices
1. **Use `@Reducer enum Path`** - For type-safe navigation destinations
2. **Use `StackState`** - For managing navigation stack state
3. **Use `.forEach(\.path, action: \.path)`** - For path reducer composition
4. **Use `@Dependency(\.dismiss)`** - For child features to dismiss themselves
5. **Handle delegate actions** - Pop stack or navigate based on child completion
6. **Extension conformances** - Add `Equatable` via extension for enum reducers
```
### references/navigation-advanced.md
```markdown
# Navigation Advanced Patterns
Multiple navigation patterns, deep linking, and recursive navigation.
## Multiple Navigation Patterns
### NavigationStack + Sheet
```swift
@Reducer
struct Feature {
@Reducer
enum Path {
case detail(Detail)
case settings(Settings)
}
@Reducer
enum Destination {
case alert(AlertState<Alert>)
case sheet(Sheet)
}
@ObservableState
struct State: Equatable {
var path = StackState<Path.State>()
@Presents var destination: Destination.State?
}
enum Action {
case path(StackActionOf<Path>)
case destination(PresentationAction<Destination.Action>)
}
var body: some Reducer<State, Action> {
Reduce { state, action in
// Handle actions
}
.forEach(\.path, action: \.path)
.ifLet(\.$destination, action: \.destination)
}
}
```
View:
```swift
struct FeatureView: View {
@Bindable var store: StoreOf<Feature>
var body: some View {
NavigationStack(
path: $store.scope(state: \.path, action: \.path)
) {
RootView()
} destination: { store in
switch store.case {
case let .detail(store):
DetailView(store: store)
case let .settings(store):
SettingsView(store: store)
}
}
.sheet(
item: $store.scope(state: \.destination?.sheet, action: \.destination.sheet)
) { store in
SheetView(store: store)
}
.alert($store.scope(state: \.destination?.alert, action: \.destination.alert))
}
}
```
## Deep Linking
### Setting Initial Path
```swift
// Set path on initialization or deep link
@ObservableState
struct State: Equatable {
var path = StackState<Path.State>()
init(deepLink: DeepLink? = nil) {
if let deepLink {
self.path = deepLink.navigationPath
}
}
}
```
### Navigating from External Event
```swift
case .deepLinkReceived(let deepLink):
state.path.removeAll()
switch deepLink {
case .detail(let id):
state.path.append(.detail(Detail.State(id: id)))
case .settings:
state.path.append(.settings(Settings.State()))
}
return .none
```
## NavigationStack State Inspection
### Checking Current Screen
```swift
// Check if specific screen is in stack
let isDetailPresented = state.path.contains { $0.is(\.detail) }
// Get specific screen state
if case let .detail(detailState) = state.path.last {
// Access detail state
}
// Count screens
let screenCount = state.path.count
```
## Recursive Navigation
For self-referencing navigation (like nested folders):
```swift
@Reducer
struct Nested {
@ObservableState
struct State: Equatable, Identifiable {
let id: UUID
var name: String = ""
var rows: IdentifiedArrayOf<State> = []
}
enum Action {
case addRowButtonTapped
indirect case rows(IdentifiedActionOf<Nested>)
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .addRowButtonTapped:
state.rows.append(State(id: UUID()))
return .none
case .rows:
return .none
}
}
.forEach(\.rows, action: \.rows) {
Self() // Recursive reference
}
}
}
```
View:
```swift
struct NestedView: View {
let store: StoreOf<Nested>
var body: some View {
Form {
TextField("Name", text: $store.name)
Button("Add Row") {
store.send(.addRowButtonTapped)
}
ForEach(
store.scope(state: \.rows, action: \.rows)
) { childStore in
NavigationLink(state: childStore) {
Text(childStore.name)
}
}
}
}
}
```
## Best Practices
1. **Use `@Presents`** - For sheets, alerts, and popovers alongside navigation
2. **Deep linking** - Set initial path or manipulate path on external events
3. **State inspection** - Use `.contains` and pattern matching to check navigation state
4. **Recursive patterns** - Use `indirect case` and `Self()` for tree structures
5. **Combine patterns** - NavigationStack + sheet/alert destinations work well together
```
### references/shared-state.md
```markdown
# Shared State
Patterns for persistent and shared state in TCA using `@Shared`.
## @Shared with AppStorage
Use `@Shared(.appStorage)` for UserDefaults-backed persistent state:
```swift
@ObservableState
struct State: Equatable {
@Shared(.appStorage("sortOrder")) var sortOrder: String = "date"
@Shared(.appStorage("showCompleted")) var showCompleted: Bool = true
}
```
## Thread-Safe Mutations
Use `.withLock` for thread-safe mutations of @Shared state:
```swift
case .view(.onChangeSortOrder(let order)):
state.$sortOrder.withLock { $0 = order }
return .none
case .view(.onToggleCompleted):
state.$showCompleted.withLock { $0.toggle() }
return .none
```
## Animated Mutations
Wrap `.withLock` in `withAnimation` for animated changes:
```swift
case .view(.onChangeTheme(let theme)):
withAnimation {
state.$themeRawValue.withLock { $0 = theme.rawValue }
}
return .none
```
## Type-Safe Access with Computed Properties
Use computed properties for type-safe access to raw-value backed state:
```swift
@ObservableState
struct State: Equatable {
@Shared(.appStorage("themeRawValue")) var themeRawValue: String = "system"
var selectedTheme: Theme {
Theme(rawValue: themeRawValue) ?? .system
}
}
```
## Shared State Between Features
Use `@Shared` without a persistence strategy for in-memory shared state:
```swift
@ObservableState
struct State: Equatable {
@Shared var userSession: UserSession
}
```
Pass the same `@Shared` reference to child features to share state:
```swift
case .view(.onShowSettings):
state.destination = .settings(
SettingsFeature.State(userSession: state.$userSession)
)
return .none
```
## FileStorageKey for Persistent Data
Use `FileStorageKey` to persist shared state to disk as JSON:
```swift
// Define the shared key
extension SharedKey where Self == FileStorageKey<IdentifiedArrayOf<SyncUp>>.Default {
static var syncUps: Self {
Self[
.fileStorage(.documentsDirectory.appending(component: "sync-ups.json")),
default: []
]
}
}
// Use in state
@ObservableState
struct State: Equatable {
@Shared(.syncUps) var syncUps
}
// Mutate with .withLock
case .view(.didAddSyncUp(let syncUp)):
state.$syncUps.withLock { $0.append(syncUp) }
return .none
case .view(.didDeleteSyncUp(let id)):
state.$syncUps.withLock { $0.remove(id: id) }
return .none
```
### Custom File Locations
```swift
extension SharedKey where Self == FileStorageKey<AppSettings>.Default {
static var appSettings: Self {
Self[
.fileStorage(.applicationSupportDirectory.appending(component: "settings.json")),
default: AppSettings()
]
}
}
```
### Requirements
- The shared type must conform to `Codable`
- Mutations are automatically persisted to disk
- File storage is asynchronous and happens in the background
## InMemoryKey for Non-Persistent Sharing
Use `InMemoryKey` for state shared across features without persistence:
```swift
// Define the shared key
extension SharedKey where Self == InMemoryKey<Stats> {
static var stats: Self {
inMemory("stats")
}
}
// Use in state
@ObservableState
struct State: Equatable {
@Shared(.stats) var stats = Stats()
}
// Mutate with .withLock
case .view(.didIncrement):
state.$stats.withLock { $0.increment() }
return .none
```
### When to Use InMemoryKey
- Sharing state between parallel features (tabs, split views)
- Temporary state that doesn't need persistence
- Testing with isolated in-memory state
- Performance-critical state that shouldn't hit disk
## Combining Persistence Strategies
```swift
@ObservableState
struct State: Equatable {
@Shared(.appStorage("theme")) var theme: String = "system" // UserDefaults
@Shared(.syncUps) var syncUps: IdentifiedArrayOf<SyncUp> // File storage
@Shared(.stats) var stats = Stats() // In-memory
}
```
## Accessing @Shared Inside Effects and Static Functions
**Key Pattern:** `@Shared` can be declared directly inside async functions and effects — no need to pass them as parameters.
### ❌ Bad: Passing @Shared as Parameter
```swift
// Overly complex - requires capturing state in closure
static func enableFeature(
featureEnabled: Shared<Bool>,
itemId: UUID?
) async throws {
featureEnabled.withLock { $0 = true }
// ...
}
// Caller must capture state
case .enableTapped:
return .run { [featureEnabled = state.$featureEnabled] _ in
try await FeatureHelper.enableFeature(
featureEnabled: featureEnabled,
itemId: itemId
)
}
```
### ✅ Good: Access @Shared Directly Inside Function
```swift
// Cleaner - function is self-contained
static func enableFeature(itemId: UUID?) async throws {
@Shared(.appStorage("featureEnabled")) var featureEnabled
$featureEnabled.withLock { $0 = true }
// ...
}
// Caller is simple
case .enableTapped:
return .run { _ in
try await FeatureHelper.enableFeature(itemId: itemId)
}
```
### Why This Works
- `@Shared` properties can be declared anywhere, not just in State structs
- The property wrapper handles shared state access automatically
- Keeps function signatures clean
- Avoids the need to capture `state.$property` in effect closures
```
### references/dependencies.md
```markdown
# Dependencies
Patterns for dependency injection in TCA.
## @DependencyClient Macro
Use `@DependencyClient` to declare dependency clients with automatic test value generation.
### Benefits for Swift 6 Strict Concurrency
`@DependencyClient` eliminates the need for manual `unimplemented` static properties, which require `nonisolated(unsafe)` in Swift 6:
```swift
// ❌ Before: Manual unimplemented pattern (requires workaround)
struct LegacyClient: Sendable {
var fetchData: @Sendable () async throws -> Data
// nonisolated(unsafe) required in Swift 6 — fragile
nonisolated(unsafe) static var unimplemented = LegacyClient(
fetchData: { fatalError("unimplemented") }
)
}
// ✅ After: @DependencyClient handles it automatically
@DependencyClient
struct ModernClient: Sendable {
var fetchData: @Sendable () async throws -> Data
}
// testValue auto-generated, no nonisolated(unsafe) needed
```
### Basic Example
```swift
@DependencyClient
struct APIClient: Sendable {
var fetchItems: @Sendable () async throws -> [Item]
var saveItem: @Sendable (Item) async throws -> Void
var deleteItem: @Sendable (UUID) async throws -> Void
}
extension APIClient: DependencyKey {
static let liveValue = APIClient(
fetchItems: {
let (data, _) = try await URLSession.shared.data(from: itemsURL)
return try JSONDecoder().decode([Item].self, from: data)
},
saveItem: { item in
var request = URLRequest(url: itemsURL)
request.httpMethod = "POST"
request.httpBody = try JSONEncoder().encode(item)
_ = try await URLSession.shared.data(for: request)
},
deleteItem: { id in
var request = URLRequest(url: itemsURL.appending(path: id.uuidString))
request.httpMethod = "DELETE"
_ = try await URLSession.shared.data(for: request)
}
)
}
extension DependencyValues {
var apiClient: APIClient {
get { self[APIClient.self] }
set { self[APIClient.self] = newValue }
}
}
```
The `@DependencyClient` macro automatically generates a `.testValue` that throws `unimplemented` errors, catching untested code paths.
### WrappedError Pattern for Typed Errors
When dependency clients need typed errors with `Equatable` conformance, wrap `Swift.Error`:
```swift
@DependencyClient
struct DataClient: Sendable {
enum Error: Swift.Error, Equatable, CustomDebugStringConvertible, Sendable {
struct WrappedError: Swift.Error, Equatable, Sendable {
let error: Swift.Error
var localizedDescription: String { error.localizedDescription }
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.localizedDescription == rhs.localizedDescription
}
}
case networkError(WrappedError)
case decodingError(WrappedError)
var debugDescription: String {
switch self {
case .networkError(let e): return "Network: \(e.localizedDescription)"
case .decodingError(let e): return "Decoding: \(e.localizedDescription)"
}
}
}
var fetchData: @Sendable () async throws(Error) -> Data
}
```
**Note:** `Swift.Error` is implicitly `Sendable`, so `WrappedError` uses plain `Sendable`, not `@unchecked Sendable`.
## Using Dependencies in Reducers
```swift
@Reducer struct FeatureName {
@Dependency(\.apiClient) var apiClient
@Dependency(\.analytics) var analytics
@Dependency(\.dismiss) var dismiss
@Dependency(\.continuousClock) var clock
}
```
## Test Dependencies
Override dependencies in tests using `withDependencies`:
```swift
let store = TestStore(initialState: .init()) {
FeatureReducer()
} withDependencies: {
$0.apiClient.fetchItems = { [Item(id: 1, name: "Test")] }
$0.analytics.track = { _ in }
$0.dismiss = DismissEffect { }
$0.continuousClock = ImmediateClock()
}
```
## Streaming Dependencies
Use `AsyncThrowingStream` for dependencies that provide streaming results:
```swift
@DependencyClient
struct SpeechClient: Sendable {
var authorizationStatus: @Sendable () -> AuthorizationStatus = { .denied }
var requestAuthorization: @Sendable () async -> AuthorizationStatus = { .denied }
var startTask: @Sendable (_ request: SpeechRequest) async
-> AsyncThrowingStream<SpeechRecognitionResult, Error> = { _ in .finished() }
}
extension SpeechClient: DependencyKey {
static let liveValue = SpeechClient(
authorizationStatus: {
SFSpeechRecognizer.authorizationStatus()
},
requestAuthorization: {
await SFSpeechRecognizer.requestAuthorization()
},
startTask: { request in
AsyncThrowingStream { continuation in
let recognizer = SFSpeechRecognizer()
let task = recognizer?.recognitionTask(with: request) { result, error in
if let result {
continuation.yield(result)
}
if let error {
continuation.finish(throwing: error)
}
if result?.isFinal == true {
continuation.finish()
}
}
continuation.onTermination = { _ in
task?.cancel()
}
}
}
)
}
```
Using streaming dependency in reducer:
```swift
case .startRecording:
return .run { send in
let request = createSpeechRequest()
for try await result in await speechClient.startTask(request) {
await send(.speechResult(result))
}
}
.cancellable(id: CancelID.speech)
```
## Preview Values
Define `previewValue` for dependencies used in SwiftUI previews:
```swift
extension AudioRecorderClient: TestDependencyKey {
static let previewValue = AudioRecorderClient(
currentTime: { 10.0 },
requestRecordPermission: { true },
startRecording: { _ in true },
stopRecording: { }
)
static let testValue = AudioRecorderClient() // Unimplemented by default
}
```
Using in previews:
```swift
#Preview {
FeatureView(
store: Store(initialState: Feature.State()) {
Feature()
} withDependencies: {
$0.audioRecorder = .previewValue
}
)
}
```
### Test Value vs Preview Value
- **`testValue`**: Auto-generated by `@DependencyClient`, throws `unimplemented` errors
- Use in tests to catch unintended dependency usage
- Forces explicit mocking of dependencies in tests
- **`previewValue`**: Custom implementation for SwiftUI previews
- Provides realistic mock data for previews
- Should return immediately without side effects
- Can use static/hardcoded values
```swift
@DependencyClient
struct DataClient: Sendable {
var fetchData: @Sendable () async throws -> [Item]
}
extension DataClient: TestDependencyKey {
static let liveValue = DataClient(
fetchData: {
// Real network call
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([Item].self, from: data)
}
)
static let previewValue = DataClient(
fetchData: {
// Mock data for previews
[
Item(id: 1, name: "Preview Item 1"),
Item(id: 2, name: "Preview Item 2")
]
}
)
// testValue is auto-generated by @DependencyClient
}
```
```
### references/effects.md
```markdown
# Effects
Patterns for handling side effects in TCA reducers.
## Basic Effects
### .run for Async Work
```swift
case .loadData:
state.isLoading = true
return .run { send in
let data = try await apiClient.fetchData()
await send(.didLoadData(.success(data)))
}
case .didLoadData(.success(let data)):
state.isLoading = false
state.data = data
return .none
```
### .send for Synchronous Action Dispatch
```swift
case .view(.onTapSave):
return .send(.saveData)
```
## Effect Error Handling
Use the `catch:` parameter for structured error handling:
```swift
case .loadItem(let id):
return .run { send in
let item = try await apiClient.fetchItem(id)
await send(.itemLoaded(item))
} catch: { error, send in
await send(.loadFailed(error))
}
```
For non-critical errors where you want to log but not surface to user:
```swift
case .syncData:
return .run { send in
try await syncClient.sync()
await send(.syncCompleted)
} catch: { error, _ in
reportIssue(error)
}
```
## Effect Composition
### .concatenate for Sequential Effects
```swift
case .onAppear:
return .concatenate(
.send(.loadData),
.send(.trackAnalytics)
)
```
### .merge for Concurrent Effects
```swift
case .onSave:
return .merge(
.send(.delegate(.didSave)),
.run { _ in await dismiss() }
)
```
## Cancellation
### .cancellable for Long-Running Effects
```swift
case .startStreaming:
return .run { send in
for try await data in client.stream() {
await send(.didReceiveData(data))
}
}
.cancellable(id: "data-stream", cancelInFlight: true)
case .stopStreaming:
return .cancel(id: "data-stream")
```
## View Lifecycle Effects
For effects that should run for the view's lifetime, use `.runTasks` with `.finish()`:
```swift
// In Reducer
@CasePathable
enum View {
case runTasks
case onAppear
}
case .view(.runTasks):
return .run { send in
for await status in statusClient.stream() {
await send(.statusChanged(status))
}
}
// No .cancellable() needed - .task handles auto-cancellation
case .view(.onAppear):
return .run { send in
let data = try await loadInitialData()
await send(.dataLoaded(data))
}
```
```swift
// In View
var body: some View {
List { /* ... */ }
.task {
await send(.runTasks).finish() // Keeps alive until view disappears
}
.onAppear {
send(.onAppear) // Immediate one-time work
}
}
```
**When to Use `.runTasks`:**
- Streaming effects (status monitors, real-time updates)
- Effects that should run for entire view lifetime
- Replaces `.onAppear` + `.onDisappear` + `.cancellable()` patterns
## Timer Effects
### Basic Timer with Clock Dependency
```swift
@Dependency(\.continuousClock) var clock
private enum CancelID { case timer }
case .toggleTimerButtonTapped:
state.isTimerActive.toggle()
return .run { [isTimerActive = state.isTimerActive] send in
guard isTimerActive else { return }
for await _ in self.clock.timer(interval: .seconds(1)) {
await send(.timerTick)
}
}
.cancellable(id: CancelID.timer, cancelInFlight: true)
case .timerTick:
state.secondsElapsed += 1
return .none
```
### Animated Timer Updates
```swift
case .toggleTimerButtonTapped:
state.isTimerActive.toggle()
return .run { [isTimerActive = state.isTimerActive] send in
guard isTimerActive else { return }
for await _ in self.clock.timer(interval: .seconds(1)) {
await send(.timerTick, animation: .default)
}
}
.cancellable(id: CancelID.timer, cancelInFlight: true)
```
### Timer with Duration and Completion
```swift
case .startCountdown:
state.timeRemaining = 60
return .run { send in
for await _ in self.clock.timer(interval: .seconds(1)) {
await send(.timerTick)
}
}
.cancellable(id: CancelID.timer)
case .timerTick:
state.timeRemaining -= 1
if state.timeRemaining <= 0 {
return .concatenate(
.cancel(id: CancelID.timer),
.send(.timerCompleted)
)
}
return .none
```
## Capturing State in Effects
### Capturing for Async Work
```swift
case .numberFactButtonTapped:
state.isLoading = true
return .run { [count = state.count] send in
let fact = try await factClient.fetch(count)
await send(.numberFactResponse(.success(fact)))
}
```
### Capturing Multiple Values
```swift
case .searchTextChanged(let text):
state.searchText = text
return .run { [text, filter = state.filter, sortOrder = state.sortOrder] send in
try await Task.sleep(for: .milliseconds(300))
let results = try await searchClient.search(
text: text,
filter: filter,
sortOrder: sortOrder
)
await send(.searchResults(results))
}
.cancellable(id: CancelID.search, cancelInFlight: true)
```
### Capturing for Conditionals
```swift
case .loadData:
return .run { [isOfflineMode = state.isOfflineMode] send in
if isOfflineMode {
let data = await cacheClient.loadFromCache()
await send(.dataLoaded(data))
} else {
let data = try await apiClient.fetchData()
await send(.dataLoaded(data))
}
}
```
```
### references/presentation.md
```markdown
# Presentation State
Patterns for managing presentation state in TCA (navigation destinations, alerts, sheets).
## Destination Management
### Unified Destination Pattern (Recommended)
Use a **single `@Presents var destination: Destination.State?` property** to manage all presentation cases (sheets, alerts, navigation). This pattern provides better type safety, cleaner state management, and simpler reducer composition.
**Benefits:**
- ✅ Type-safe: Compiler ensures all presentation cases are handled
- ✅ Mutually exclusive: Only one presentation can be active at a time
- ✅ Simpler composition: One `ifLet` instead of multiple properties
- ✅ Clearer code: All navigation flows in a single enum
**When to use:** Default choice for any feature with multiple presentation types.
```swift
@Reducer
struct Feature {
@Reducer
enum Destination {
case sheet(SheetFeature)
case dialog(ConfirmationDialog)
case navigationDrill(DetailFeature)
}
@ObservableState
struct State: Equatable {
@Presents var destination: Destination.State? // ← Single source of truth
}
enum Action {
case destination(PresentationAction<Destination.Action>)
case view(ViewAction)
case delegate(DelegateAction)
}
enum ViewAction {
case showSheet
case showDialog
case showDetail
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .view(.showSheet):
state.destination = .sheet(SheetFeature.State())
return .none
case .view(.showDialog):
state.destination = .dialog(ConfirmationDialog.State())
return .none
case .view(.showDetail):
state.destination = .navigationDrill(DetailFeature.State())
return .none
case .destination:
return .none
}
}
.ifLet(\.$destination, action: \.destination) // ← Single composition point
}
}
```
### Avoid: Multiple `@Presents` Properties
❌ **Don't do this:** Separate `@Presents` properties for each destination
```swift
// ❌ Avoid this pattern
struct BadState {
@Presents var sheetDestination: SheetFeature.State?
@Presents var alertDestination: AlertState<AlertAction>?
@Presents var navigationDestination: DetailFeature.State?
// Multiple properties = complexity, harder to test
}
```
Multiple properties lead to:
- ✗ State complexity: Managing multiple presentation states
- ✗ Testing burden: Verifying combinations of properties
- ✗ Error-prone: Easy to show multiple presentations simultaneously
- ✗ Reducer clutter: Multiple `ifLet` chains
### Basic Destination Management
```swift
@Reducer(state: .equatable)
public enum Destination {
case detail(DetailReducer)
case settings(SettingsReducer)
case alert(AlertReducer)
}
// In main reducer
case .view(.didTapDetail):
state.destination = .detail(DetailReducer.State())
return .none
case .destination(.presented(.detail(.delegate(.didComplete)))):
state.destination = nil
return .send(.delegate(.userDidCompleteFlow))
```
## Alert Management
```swift
public enum Alert: Equatable {
case confirmDelete
case retryAction
case showError(Error)
}
case .view(.didTapDelete):
state.alert = .confirmDelete
return .none
case .alert(.presented(.confirmDelete)):
return .send(.deleteItem)
```
## Multiple Presentation Destinations
### Sheet, Popover, and Navigation Drill-Down
```swift
@Reducer
struct MultipleDestinations {
@Reducer
enum Destination {
case drillDown(Counter)
case popover(Counter)
case sheet(Counter)
}
@ObservableState
struct State: Equatable {
@Presents var destination: Destination.State?
}
enum Action {
case destination(PresentationAction<Destination.Action>)
case showDrillDown
case showPopover
case showSheet
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .showDrillDown:
state.destination = .drillDown(Counter.State())
return .none
case .showPopover:
state.destination = .popover(Counter.State())
return .none
case .showSheet:
state.destination = .sheet(Counter.State())
return .none
case .destination:
return .none
}
}
.ifLet(\.$destination, action: \.destination)
}
}
```
### View with Multiple Presentation Modifiers
```swift
struct MultipleDestinationsView: View {
@Bindable var store: StoreOf<MultipleDestinations>
var body: some View {
Form {
Button("Show drill-down") {
store.send(.showDrillDown)
}
Button("Show popover") {
store.send(.showPopover)
}
Button("Show sheet") {
store.send(.showSheet)
}
}
.navigationDestination(
item: $store.scope(
state: \.destination?.drillDown,
action: \.destination.drillDown
)
) { store in
CounterView(store: store)
}
.popover(
item: $store.scope(
state: \.destination?.popover,
action: \.destination.popover
)
) { store in
CounterView(store: store)
}
.sheet(
item: $store.scope(
state: \.destination?.sheet,
action: \.destination.sheet
)
) { store in
CounterView(store: store)
}
}
}
```
## Combining Alerts with Other Destinations
```swift
@Reducer
struct Feature {
@Reducer
enum Destination {
case alert(AlertState<Alert>)
case detail(Detail)
case settings(Settings)
}
@CasePathable
enum Alert {
case confirmDelete
case confirmLogout
}
@ObservableState
struct State: Equatable {
@Presents var destination: Destination.State?
}
enum Action {
case destination(PresentationAction<Destination.Action>)
case showAlert(Alert)
case showDetail
case showSettings
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .showAlert(let alert):
state.destination = .alert(alertState(for: alert))
return .none
case .showDetail:
state.destination = .detail(Detail.State())
return .none
case .showSettings:
state.destination = .settings(Settings.State())
return .none
case .destination(.presented(.alert(.confirmDelete))):
state.destination = nil
return .send(.deleteItem)
case .destination:
return .none
}
}
.ifLet(\.$destination, action: \.destination)
}
func alertState(for alert: Alert) -> AlertState<Alert> {
switch alert {
case .confirmDelete:
return AlertState {
TextState("Delete Item")
} actions: {
ButtonState(role: .destructive, action: .confirmDelete) {
TextState("Delete")
}
ButtonState(role: .cancel) {
TextState("Cancel")
}
}
case .confirmLogout:
return AlertState {
TextState("Log Out")
} actions: {
ButtonState(role: .destructive, action: .confirmLogout) {
TextState("Log Out")
}
}
}
}
}
```
View:
```swift
struct FeatureView: View {
@Bindable var store: StoreOf<Feature>
var body: some View {
VStack {
// Content
}
.alert($store.scope(state: \.destination?.alert, action: \.destination.alert))
.sheet(
item: $store.scope(state: \.destination?.detail, action: \.destination.detail)
) { store in
DetailView(store: store)
}
.sheet(
item: $store.scope(state: \.destination?.settings, action: \.destination.settings)
) { store in
SettingsView(store: store)
}
}
}
```
```
### references/testing-fundamentals.md
```markdown
# Testing Fundamentals
Core requirements and setup patterns for testing TCA features with TestStore and SwiftTesting.
## Equatable Conformance Requirement
**CRITICAL**: To test TCA reducers with `TestStore`, the reducer's `State` **must** conform to `Equatable`. This is a hard requirement for TCA testing.
**Key Rules**:
1. All `State` structs in reducers you want to test must conform to `Equatable`
2. **Every property type** within that `State` must also conform to `Equatable`
3. This includes nested child feature states
4. This includes types wrapped by `@Presents` (e.g., `@Presents var destination: Destination.State?` requires `Destination.State` to be `Equatable`)
5. The conformance cascades down - if any nested type cannot be made `Equatable`, the parent `State` cannot be tested with `TestStore`
**Example**:
```swift
// ❌ Cannot test - State doesn't conform to Equatable
@ObservableState
struct State {
var items: [Item] = []
@Presents var destination: Destination.State?
}
// ✅ Can test - State and all nested types conform to Equatable
@ObservableState
struct State: Equatable {
var items: [Item] = [] // Item must be Equatable
@Presents var destination: Destination.State? // Destination.State must be Equatable
}
// Destination.State must be Equatable
@Reducer enum Destination {
case settings(SettingsFeature) // SettingsFeature.State must be Equatable
}
extension Destination.State: Equatable {}
// And any child features used by Destination
@ObservableState
struct SettingsFeature.State: Equatable {
// All properties must be Equatable
}
```
**When you can't make State Equatable**:
- If any nested type cannot conform to `Equatable`, you cannot use `TestStore` for that reducer
- Consider refactoring to extract testable logic into child reducers with `Equatable` states
- Or test the non-`Equatable` types separately without `TestStore`
## Basic Test Suite
```swift
@Suite("Feature Name")
@MainActor
struct FeatureNameTests {
typealias Reducer = FeatureNameReducer
// Test data and helpers
private let testData = TestData()
private func makeStore(
initialState: Reducer.State = .init(),
dependencies: (inout DependencyValues) -> Void = { _ in }
) -> TestStoreOf<Reducer> {
TestStore(initialState: initialState) {
Reducer()
} withDependencies: {
$0.apiClient = .test()
$0.analytics = .test()
$0.continuousClock = ImmediateClock()
$0.notificationFeedbackGenerator = .test()
$0.dismiss = DismissEffect { }
dependencies(&$0)
}
}
}
```
## Test Naming Conventions
```swift
// ✅ Descriptive test names that explain the scenario
@Test("onAppear loads data successfully")
func testOnAppearSuccess() async { }
@Test("handles network error gracefully")
func testNetworkError() async { }
@Test("validates form before submission")
func testFormValidation() async { }
// For complex scenarios, use underscores
@Test("user_can_add_multiple_items_and_save")
func testUserCanAddMultipleItemsAndSave() async { }
```
## Test Store Setup
### Basic Setup
```swift
private func makeStore(
initialState: Reducer.State = .init(),
dependencies: (inout DependencyValues) -> Void = { _ in }
) -> TestStoreOf<Reducer> {
TestStore(initialState: initialState) {
Reducer()
} withDependencies: {
// Default test dependencies
$0.apiClient = .test()
$0.analytics = .test()
$0.continuousClock = ImmediateClock()
$0.notificationFeedbackGenerator = .test()
$0.dismiss = DismissEffect { }
// Custom dependencies
dependencies(&$0)
}
}
```
### Custom State Setup
```swift
private func makeStore(
shiftId: Int = 1,
allowsMultipleSegments: Bool = true
) -> TestStoreOf<EditShiftReducer> {
let dependencies = ShiftOperationsDependencies(
allowsMultipleWorkSegments: allowsMultipleSegments,
allowsConsentOverride: true
)
let state = withDependencies {
$0.shiftOperationsDependencies = dependencies
} operation: {
EditShiftReducer.State(shiftId: shiftId)
}
return TestStore(initialState: state) {
EditShiftReducer()
} withDependencies: {
$0.shiftClient = .test()
$0.shiftOperationsDependencies = dependencies
}
}
```
## Mock Dependencies
```swift
extension APIClient {
static func test(
fetchData: @escaping () async throws -> [Item] = { [] },
saveData: @escaping (Item) async throws -> Void = { _ in }
) -> Self {
Self(
fetchData: fetchData,
saveData: saveData
)
}
}
extension Analytics {
static func test(
track: @escaping (Event) -> Void = { _ in }
) -> Self {
Self(track: track)
}
}
```
```
### references/testing-patterns.md
```markdown
# Testing Patterns
Core patterns for testing actions, state changes, dependencies, errors, and presentations in TCA.
## Action Testing
### Basic Action Testing
```swift
@Test("onAppear triggers data loading")
func testOnAppear() async {
let store = makeStore()
await store.send(.view(.onAppear))
await store.receive(.loadData) {
$0.isLoading = true
}
}
```
### Success Flow Testing
```swift
@Test("successful data loading flow")
func testSuccessfulDataLoading() async {
let testData = [Item(id: 1, name: "Test")]
let store = makeStore {
$0.apiClient.fetchData = { testData }
}
await store.send(.view(.onAppear))
await store.receive(.loadData) {
$0.isLoading = true
}
await store.receive(.didLoadData(.success(testData))) {
$0.isLoading = false
$0.data = testData
}
}
```
### Delegate Action Testing
```swift
@Test("notifies parent on completion")
func testDelegateNotification() async {
let store = makeStore()
await store.send(.view(.didTapSave))
await store.receive(.delegate(.userDidCompleteFlow))
}
```
### Receive Without State Change
**IMPORTANT**: When receiving an action that doesn't change state, omit the closure entirely:
```swift
// ✅ No state change expected - omit closure
await store.receive(\.delegate.bundleSelected)
await store.receive(\.delegate.cancelled)
// ❌ WRONG - causes "Expected state to change, but no change occurred"
await store.receive(\.delegate.bundleSelected) { _ in }
await store.receive(\.delegate.cancelled) { _ in }
```
The closure in `receive` tells TestStore you expect state mutations. Using `{ _ in }` or `{ $0 }` when no change occurs will fail the test.
## State Verification
### Basic State Verification
```swift
await store.send(.view(.didTapSave)) {
$0.isLoading = true
$0.canSave = false
}
```
### Complex State Verification
```swift
await store.receive(.didLoadData(.success(testData))) {
$0.isLoading = false
$0.data = testData
$0.isEmpty = false
$0.canSave = true
}
```
### Computed Property Testing
```swift
@Test("computed properties work correctly")
func testComputedProperties() async {
var state = Reducer.State()
// Test empty state
#expect(state.isEmpty == true)
#expect(state.canSave == false)
// Test with data
state.data = [Item(id: 1, name: "Test")]
#expect(state.isEmpty == false)
#expect(state.canSave == true)
}
```
## Dependency Testing
### Dependency Verification
```swift
@Test("tracks analytics events")
func testAnalyticsTracking() async {
var trackedEvents: [AnalyticsEvent] = []
let store = makeStore {
$0.analytics = .test { event in
trackedEvents.append(event)
}
}
await store.send(.view(.onAppear))
#expect(trackedEvents.count == 1)
#expect(trackedEvents.first == .screenViewed)
}
```
### Multiple Dependencies Testing
```swift
@Test("coordinates multiple dependencies")
func testMultipleDependencies() async {
var analyticsEvents: [AnalyticsEvent] = []
var apiCalls: [String] = []
let store = makeStore {
$0.analytics = .test { event in
analyticsEvents.append(event)
}
$0.apiClient = .test { endpoint in
apiCalls.append(endpoint)
return TestData()
}
}
await store.send(.view(.onAppear))
#expect(apiCalls.contains("fetchData"))
#expect(analyticsEvents.contains(.screenViewed))
}
```
## Error Testing
### Error State Verification
```swift
@Test("shows error alert on failure")
func testErrorAlert() async {
let error = NetworkError.timeout
let store = makeStore {
$0.apiClient.fetchData = { throw error }
}
await store.send(.view(.onAppear))
await store.receive(.didLoadData(.failure(error))) {
$0.alert = .error(error)
}
#expect(store.state.alert != nil)
}
```
### Error Recovery Testing
```swift
@Test("can retry after error")
func testErrorRetry() async {
var callCount = 0
let store = makeStore {
$0.apiClient.fetchData = {
callCount += 1
if callCount == 1 {
throw NetworkError.timeout
}
return [Item(id: 1, name: "Test")]
}
}
// First attempt fails
await store.send(.view(.onAppear))
await store.receive(.didLoadData(.failure(NetworkError.timeout)))
// Retry succeeds
await store.send(.alert(.presented(.retry)))
await store.receive(.didLoadData(.success([Item(id: 1, name: "Test")])))
#expect(callCount == 2)
}
```
## Presentation Testing
### Destination Testing
```swift
@Test("navigates to detail screen")
func testNavigationToDetail() async {
let store = makeStore()
await store.send(.view(.didTapDetail)) {
$0.destination = .detail(DetailReducer.State())
}
}
@Test("handles detail completion")
func testDetailCompletion() async {
let store = makeStore()
// Navigate to detail
await store.send(.view(.didTapDetail))
// Complete detail flow
await store.send(.destination(.presented(.detail(.delegate(.didComplete))))) {
$0.destination = nil
}
await store.receive(.delegate(.userDidCompleteFlow))
}
```
### Alert Testing
```swift
@Test("shows confirmation alert")
func testConfirmationAlert() async {
let store = makeStore()
await store.send(.view(.didTapDelete)) {
$0.alert = .confirmDelete
}
await store.send(.alert(.presented(.confirmDelete))) {
$0.alert = nil
}
await store.receive(.deleteItem)
}
```
## Async Testing
### Async Effect Testing
```swift
@Test("handles async operations")
func testAsyncOperations() async {
let expectation = Expectation(description: "Async operation completes")
let store = makeStore {
$0.apiClient.fetchData = {
try await Task.sleep(nanoseconds: 1_000_000)
expectation.fulfill()
return [Item(id: 1, name: "Test")]
}
}
await store.send(.view(.onAppear))
await store.receive(.didLoadData(.success([Item(id: 1, name: "Test")])))
await expectation.await()
}
```
### Effect Cancellation Testing
```swift
@Test("cancels effects on dismiss")
func testEffectCancellation() async {
var isCancelled = false
let store = makeStore {
$0.apiClient.fetchData = {
try await Task.sleep(nanoseconds: 1_000_000)
if Task.isCancelled {
isCancelled = true
throw CancellationError()
}
return []
}
}
await store.send(.view(.onAppear))
await store.send(.view(.onDisappear))
try await Task.sleep(nanoseconds: 2_000_000)
#expect(isCancelled == true)
}
```
## Testing @Shared State
### Use .dependencies Trait for Test Isolation
Add `.dependencies` to `@Suite` to ensure each test gets fresh dependencies:
```swift
@MainActor
@Suite(
"SettingsFeature",
.dependency(\.continuousClock, ImmediateClock()),
.dependencies // Ensures fresh dependencies per test for determinism
)
struct SettingsFeatureTests {
// ...
}
```
Without `.dependencies`, tests may share state and produce non-deterministic results.
### Setting Up @Shared in Tests
Declare `@Shared` variables at test scope to both initialize and verify state:
```swift
@Test("enables notifications when toggled on")
func testEnablesNotifications() async {
// Declare @Shared at test scope for verification
@Shared(.appStorage("notificationsEnabled")) var notificationsEnabled = false
let store = TestStore(initialState: SettingsFeature.State()) {
SettingsFeature()
} withDependencies: {
$0.notificationClient.requestAuthorization = { true }
}
await store.send(.view(.notificationToggleTapped))
await store.receive(\.delegate.notificationsConfigured) {
// Assert @Shared mutation in state closure
$0.$notificationsEnabled.withLock { $0 = true }
}
// Can also verify outside store
#expect(notificationsEnabled == true)
}
```
### Asserting @Shared Mutations in receive
When effects mutate `@Shared` state, assert those changes in the `receive` closure:
```swift
await store.receive(\.delegate.settingsSaved) {
$0.isSaving = false
// Assert @Shared mutations using withLock
$0.$darkModeEnabled.withLock { $0 = true }
$0.$accentColor.withLock { $0 = "blue" }
}
```
**IMPORTANT:** For TestStore to observe `@Shared` mutations, the `@Shared` property must be declared in State and captured before the `.run` closure. Declaring `@Shared` inside the effect creates a disconnected instance that TestStore cannot observe.
### Testing @Shared Toggle Actions
```swift
@Test("dark mode toggle updates shared setting")
func testDarkModeToggle() async {
@Shared(.appStorage("darkModeEnabled")) var darkModeEnabled = false
let store = TestStore(initialState: AppearanceFeature.State()) {
AppearanceFeature()
}
await store.send(.view(.darkModeToggled(true))) {
$0.$darkModeEnabled.withLock { $0 = true }
}
await store.finish()
}
```
```
### references/testing-advanced.md
```markdown
# Testing Advanced Patterns
Advanced testing techniques including time control, keypath matching, exhaustivity, and complex scenarios.
## Given-When-Then Pattern
```swift
@Test("user can save form with valid data")
func testSaveFormWithValidData() async {
// GIVEN: Valid form data
let validData = FormData.test()
let store = makeStore()
// WHEN: User submits form
await store.send(.view(.didChangeData(validData)))
await store.send(.view(.didTapSave))
// THEN: Form is saved successfully
await store.receive(.didSaveData(.success(()))) {
$0.isSaved = true
}
}
```
## State Machine Testing
```swift
@Test("transitions through loading states correctly")
func testLoadingStateTransitions() async {
let store = makeStore()
// Initial state
#expect(store.state.loadingState == .idle)
// Start loading
await store.send(.view(.onAppear)) {
$0.loadingState = .loading
}
// Load success
await store.receive(.didLoadData(.success([]))) {
$0.loadingState = .loaded([])
}
}
```
## Edge Case Testing
```swift
@Test("handles empty data gracefully")
func testEmptyData() async {
let store = makeStore {
$0.apiClient.fetchData = { [] }
}
await store.send(.view(.onAppear))
await store.receive(.didLoadData(.success([]))) {
$0.data = []
$0.isEmpty = true
$0.showEmptyState = true
}
}
```
## Time-Based Testing
### Debouncing
```swift
@Test("debounces user input correctly")
func testDebouncedInput() async {
let store = makeStore {
$0.continuousClock = ImmediateClock()
}
// Send rapid input
await store.send(.view(.didChangeText("a")))
await store.send(.view(.didChangeText("ab")))
await store.send(.view(.didChangeText("abc")))
// Should only receive debounced action
await store.receive(.searchDebounced("abc"))
}
```
### TestClock for Controlled Time
Use `TestClock` when you need precise control over time advancement:
```swift
@Test("timer advances correctly")
func testTimer() async {
let clock = TestClock()
let store = TestStore(initialState: Timer.State()) {
Timer()
} withDependencies: {
$0.continuousClock = clock
}
// Start timer
await store.send(.toggleTimerButtonTapped) {
$0.isTimerActive = true
}
// Advance time by 1 second
await clock.advance(by: .seconds(1))
await store.receive(\.timerTick) {
$0.secondsElapsed = 1
}
// Advance time by multiple seconds
await clock.advance(by: .seconds(3))
await store.receive(\.timerTick) {
$0.secondsElapsed = 2
}
await store.receive(\.timerTick) {
$0.secondsElapsed = 3
}
await store.receive(\.timerTick) {
$0.secondsElapsed = 4
}
}
```
### TestClock vs ImmediateClock
- **ImmediateClock**: All time-based operations complete immediately
- Use for: Debouncing, delays, simple timeouts
- Fast tests with no actual time passing
- **TestClock**: Manual control over time advancement
- Use for: Timers, intervals, precise time-based behavior
- Test exact timing sequences
```swift
// ImmediateClock example - delays complete instantly
@Test("loads data after delay")
func testDelayedLoad() async {
let store = makeStore {
$0.continuousClock = ImmediateClock()
}
await store.send(.loadData)
await store.receive(\.dataLoaded) // Immediate, no waiting
}
// TestClock example - control time advancement
@Test("polls every 5 seconds")
func testPolling() async {
let clock = TestClock()
let store = makeStore {
$0.continuousClock = clock
}
await store.send(.startPolling)
await clock.advance(by: .seconds(5))
await store.receive(\.pollResponse)
await clock.advance(by: .seconds(5))
await store.receive(\.pollResponse)
}
```
## KeyPath-Based Action Receiving
Use keypath syntax for more concise action matching:
```swift
// Instead of this:
await store.receive(.numberFactResponse(.success("Test fact"))) {
$0.fact = "Test fact"
}
// Use this:
await store.receive(\.numberFactResponse.success) {
$0.fact = "Test fact"
}
```
### Complex KeyPaths
```swift
// Nested actions
await store.receive(\.destination.presented.detail.delegate.didComplete)
// ForEach actions
await store.receive(\.todos[id: todoID].toggleCompleted)
// Path actions
await store.receive(\.path[id: screenID].screenA.didSave)
```
### Partial Matching
```swift
// Match any success response
await store.receive(\.numberFactResponse.success) {
$0.fact = "Test fact"
}
// Match any failure response
await store.receive(\.numberFactResponse.failure) {
$0.alert = AlertState { TextState("Error") }
}
// Match delegate action
await store.receive(\.delegate) {
// State changes
}
```
## Test Exhaustivity Control
TestStore has an `exhaustivity` property that controls whether all state changes and received actions must be explicitly asserted. By default, exhaustivity is `.on`, meaning you must assert every state change. Set it to `.off` for complex flows where you only care about specific outcomes.
### When to Use `.off`
Use `store.exhaustivity = .off` when:
1. **Complex async flows** - When testing flows with many intermediate state changes that aren't relevant to the test
2. **Third-party state** - When using `@FetchOne`, `@Fetch`, or other property wrappers that manage their own state
3. **Focus on outcomes** - When you only care about the final result, not every intermediate step
4. **Integration-style tests** - When testing end-to-end flows without micromanaging every state mutation
### Pattern
```swift
@Test("available status triggers sync when identity exists")
func availableStatusWithIdentity() async {
let testIdentity = StoredAppleIdentity(appleUserId: "test-user-id")
let store = makeStore {
$0.appleIdentityStore.load = { testIdentity }
}
// Turn off exhaustivity - we only care about specific actions being sent
store.exhaustivity = .off
await store.send(.iCloudAccountStatusChanged(.available))
// Assert only the actions we care about
await store.receive(\.fetchUnclaimedShareItems)
await store.receive(\.ensureSharedItemSubscription)
// Other state changes and actions can happen without failing the test
}
```
### With State Verification
You can still verify specific state even with exhaustivity off:
```swift
@Test("edit mode populates from existing item")
func editModePopulatesFromExisting() async {
let existingItem = makeTestExistingItem()
let store = makeStore(initialState: .editing(existingItem))
store.exhaustivity = .off
#expect(store.state.mode == .edit(existingItem: existingItem))
#expect(store.state.itemTypeEditor != nil)
// Can check specific state properties without asserting every change
if case .link(let linkState) = store.state.itemTypeEditor {
#expect(linkState.urlInput == "https://example.com")
#expect(linkState.preview?.title == "Example")
}
}
```
### Best Practices
**DO**:
- Use exhaustivity off for integration tests focusing on end results
- Still assert the critical state changes and actions you care about
- Use it when dealing with @Fetch/@FetchOne that have internal state management
- Document why exhaustivity is off in complex tests
**DON'T**:
- Use it as a crutch to avoid thinking about state changes
- Turn it off for simple unit tests where all state is relevant
- Forget to assert the important outcomes just because exhaustivity is off
- Use it to hide bugs or unexpected state changes
### Example: Testing with @FetchOne
```swift
@Test("onAppear sets default list when no selection")
func onAppearSetsDefaultList() async {
let inboxListID = UUID()
let store = makeStore {
$0.defaultDatabase.read = { db in
return StashItemList(id: inboxListID, name: "Inbox", ...)
}
}
// @FetchOne property wrapper manages its own state internally
store.exhaustivity = .off
await store.send(.view(.onAppear))
await store.receive(.setSelectedListID(inboxListID))
// We don't need to assert $selectedList changes because @FetchOne handles it
}
```
```
### references/testing-utilities.md
```markdown
# Testing Utilities
Test data patterns, organization, confirmation dialogs, dependency mocking, and @Shared state testing.
## Test Data Patterns
### Test Data Factories
```swift
extension Item {
static func test(
id: Int = 1,
name: String = "Test Item",
isEnabled: Bool = true
) -> Self {
Self(
id: id,
name: name,
isEnabled: isEnabled
)
}
}
```
### Test Data Arrays
```swift
extension Array where Element == Item {
static func test(count: Int = 3) -> [Item] {
(1...count).map { Item.test(id: $0, name: "Item \($0)") }
}
}
```
### Test Data Constants (Preferred)
Use enum-based ID constants for reproducible tests instead of creating new UUIDs:
```swift
// ✅ Good: Consistent test data constants
enum TestData {
static let itemId1 = UUID(uuidString: "00000000-0000-0000-0000-000000000001")!
static let itemId2 = UUID(uuidString: "00000000-0000-0000-0000-000000000002")!
}
@Test("sets item as favorite")
func testSetFavorite() async {
let setFavoriteCalled = LockIsolated<UUID?>(nil)
// Use constant - reproducible
await store.send(.view(.onSetFavoriteTapped(TestData.itemId1)))
#expect(setFavoriteCalled.value == TestData.itemId1)
}
// ❌ Avoid: Creating new UUIDs each test run
let itemId = UUID() // Different every run, harder to debug
```
## Test Organization
### Test Grouping with MARK
```swift
@Suite("Feature Name")
@MainActor
struct FeatureNameTests {
// MARK: - Setup
private func makeStore() -> TestStoreOf<Reducer> { ... }
// MARK: - Initialization Tests
@Test("initializes with correct default state")
func testInitialization() async { ... }
// MARK: - User Interaction Tests
@Test("responds to user taps")
func testUserInteraction() async { ... }
// MARK: - Data Loading Tests
@Test("loads data on appear")
func testDataLoading() async { ... }
// MARK: - Error Handling Tests
@Test("handles network errors")
func testErrorHandling() async { ... }
// MARK: - Navigation Tests
@Test("navigates to detail screen")
func testNavigation() async { ... }
}
```
### Test Documentation
```swift
/// Tests the initialization of the EditShift feature.
/// Verifies that:
/// - Shift is loaded correctly
/// - State is set up with correct initial values
/// - Properties are properly initialized
@Test("onAppear loads shift successfully")
func testOnAppearLoadsShift() async { ... }
```
## ConfirmationDialogState Testing
When testing features that use `ConfirmationDialogState`, follow these patterns:
### Basic Pattern
```swift
@Test("confirmation dialog action deletes with preserve")
func testConfirmationDialogAction() async {
var state = Feature.State()
state.confirmDeleteBundleId = testBundleId
state.confirmationDialog = .deleteBundle(name: "Test", itemCount: 2)
let deleteCalled = LockIsolated<(UUID, Bool)?>(nil)
let store = TestStore(initialState: state) {
Feature()
} withDependencies: {
$0.bundleClient.delete = { id, preserveItems in
deleteCalled.setValue((id, preserveItems))
}
}
// Send the presented action and expect BOTH state changes
await store.send(.confirmationDialog(.presented(.moveItemsToInbox))) {
$0.confirmDeleteBundleId = nil
$0.confirmationDialog = nil // Dialog clears on action
}
// CRITICAL: Exhaust effects from .run blocks
await store.finish()
#expect(deleteCalled.value?.0 == testBundleId)
#expect(deleteCalled.value?.1 == true)
}
```
### Key Points
1. **Clear both state properties**: When a dialog action fires, set both `confirmationDialog = nil` and any tracking ID to `nil`
2. **Use `await store.finish()`**: Effects from `.run { }` blocks must be exhausted after sending actions
3. **Dialog dismiss**: For `.dismiss` action, only `confirmationDialog` clears (not tracking IDs)
```swift
await store.send(.confirmationDialog(.dismiss)) {
$0.confirmationDialog = nil
// Note: confirmDeleteBundleId stays set (becomes stale but harmless)
}
```
## Dependency Mocking Completeness
When modifying reducers to call new dependencies, **always update corresponding test mocks**:
### Common Pitfall
```swift
// Reducer calls TWO dependencies:
case .view(.saveTapped):
return .run { send in
try await bundleClient.update(id, name, color)
try await bundleClient.updateTemporary(id, isTemporary) // NEW!
await send(.delegate(.saved))
}
// ❌ Test only mocks ONE - will fail with unimplemented dependency
let store = TestStore(...) {
$0.bundleClient.update = { ... }
// Missing: $0.bundleClient.updateTemporary
}
// ✅ Mock ALL dependencies called by the action
let store = TestStore(...) {
$0.bundleClient.update = { ... }
$0.bundleClient.updateTemporary = { _, _ in } // Added!
}
```
### Rule
When you add a dependency call to a reducer, grep for existing tests and add the mock.
## LockIsolated Patterns
Use `LockIsolated` for thread-safe value capture in tests.
### setValue() vs withLock
```swift
// ✅ Preferred: Clean setter
let capturedId = LockIsolated<UUID?>(nil)
$0.itemClient.setFavorite = { id in
capturedId.setValue(id)
}
#expect(capturedId.value == expectedId)
// Also valid: withLock for complex mutations
let callHistory = LockIsolated<[String]>([])
callHistory.withLock { $0.append("called") }
```
### Boolean Tracking
```swift
let wasCalled = LockIsolated(false)
$0.client.someMethod = {
wasCalled.setValue(true)
}
#expect(wasCalled.value == true)
```
## Testing @Shared State Changes
### Using store.assert { } After Effects
When testing effects that modify `@Shared` state, use `store.assert { }` to verify state after effects complete:
```swift
@Test("confirm action enables feature via Shared")
func testConfirmEnablesFeature() async {
var state = Feature.State()
state.confirmationAlert = FeatureHelper.confirmationAlertState()
let store = TestStore(initialState: state) {
Feature()
} withDependencies: {
$0.itemClient.setFavorite = { _ in }
}
// Action triggers effect that modifies @Shared
await store.send(.confirmationAlert(.presented(.confirm(itemId: nil)))) {
$0.confirmationAlert = nil
}
// Verify @Shared state after effect completes
store.assert {
$0.$featureEnabled.withLock { $0 = true }
}
}
```
## Summary
**Key Principles**:
1. Use SwiftTesting `@Suite` and `@Test` for test organization
2. Create reusable `makeStore` helpers with default test dependencies
3. Use `ImmediateClock` for time-based tests
4. Test state transitions explicitly with closure assertions
5. Mock dependencies with `.test()` factory methods
6. Test both success and error paths
7. Verify navigation and presentation state changes
8. Use test data factories for consistent test data
9. Organize tests with MARK comments
10. Document complex tests with comments
11. Use `await store.finish()` to exhaust effects from `.run` blocks
12. When adding new dependency calls, update ALL test mocks
13. Use `LockIsolated.setValue()` for cleaner value capture
14. Use `store.assert { }` to verify @Shared state after effects
15. Use test data constants (e.g., `BundleTestData.bundleId1`) instead of `UUID()`
```
### references/performance.md
```markdown
# Performance Considerations
Detailed patterns for optimizing TCA features.
## State Updates
### Computed Properties vs Stored Properties
```swift
// ✅ Good: Use computed properties for derived state
@ObservableState
public struct State: Equatable {
var items: [Item] = []
var selectedIds: Set<Int> = []
// Computed property - recalculated only when dependencies change
var selectedItems: [Item] {
items.filter { selectedIds.contains($0.id) }
}
// Computed property for validation
var canSave: Bool {
!selectedItems.isEmpty && !isLoading
}
}
// ❌ Avoid: Storing derived state that could be computed
@ObservableState
public struct State: Equatable {
var items: [Item] = []
var selectedIds: Set<Int> = []
var selectedItems: [Item] = [] // This could become stale
}
```
### Efficient State Updates
```swift
// ✅ Good: Batch related state changes
case .view(.didSelectMultipleItems(let ids)):
state.selectedIds.formUnion(ids)
return .none
// ✅ Good: Use value types for better performance
@ObservableState
public struct State: Equatable {
var items: IdentifiedArrayOf<Item.State> // Better than [Item]
var filters: FilterState // Value type
}
// ❌ Avoid: Multiple separate state updates
case .view(.didSelectMultipleItems(let ids)):
for id in ids {
state.selectedIds.insert(id) // Multiple updates
}
return .none
```
## Effect Performance
### Debouncing and Cancellation
```swift
// ✅ Good: Debounce user input
case .view(.didChangeSearchText(let text)):
state.searchText = text
return .run { send in
try await Task.sleep(for: .milliseconds(300))
await send(.searchDebounced(text))
}
.cancellable(id: "search", cancelInFlight: true)
// ✅ Good: Cancel effects when appropriate
case .view(.onDisappear):
return .cancel(id: "data-loading")
// ✅ Good: Use weak capture in closures
case .loadData:
return .run { [weak self] send in
guard let self = self else { return }
let data = try await self.apiClient.fetchData()
await send(.didLoadData(.success(data)))
}
```
## Reducer Composition
```swift
// ✅ Good: Use .forEach for collections
public var body: some ReducerOf<Self> {
Reduce { state, action in
// Main logic
}
.forEach(\.items, action: \.items) {
ItemReducer()
}
}
// ✅ Good: Use .ifLet for optional child reducers
public var body: some ReducerOf<Self> {
Reduce { state, action in
// Main logic
}
.ifLet(\.childState, action: \.childAction) {
ChildReducer()
}
}
```
## Sharing Logic
```swift
// ❌ Avoid: Sharing logic through actions (inefficient)
case .buttonTapped:
state.count += 1
return .send(.sharedComputation)
case .toggleChanged:
state.isEnabled.toggle()
return .send(.sharedComputation)
case .sharedComputation:
// Shared work and effects
return .run { send in
// Shared effect
}
// ✅ Good: Share logic through methods
case .buttonTapped:
state.count += 1
return self.sharedComputation(state: &state)
case .toggleChanged:
state.isEnabled.toggle()
return self.sharedComputation(state: &state)
// Helper method in reducer
func sharedComputation(state: inout State) -> Effect<Action> {
// Shared work and effects
return .run { send in
// Shared effect
}
}
```
## CPU-Intensive Calculations
```swift
// ❌ Avoid: CPU-intensive work in reducer (blocks main thread)
case .buttonTapped:
var result = 0
for value in someLargeCollection {
// Intense computation
result += complexCalculation(value)
}
state.result = result
return .none
// ✅ Good: Move CPU work to effects with cooperative yielding
case .buttonTapped:
return .run { send in
var result = 0
for (index, value) in someLargeCollection.enumerated() {
// Intense computation
result += complexCalculation(value)
// Yield every 1000 iterations to cooperate in thread pool
if index.isMultiple(of: 1_000) {
await Task.yield()
}
}
await send(.computationResponse(result))
}
case let .computationResponse(result):
state.result = result
return .none
```
## High-Frequency Actions
```swift
// ❌ Avoid: Sending actions for every small change
case .startButtonTapped:
return .run { send in
var count = 0
let max = await self.eventsClient.count()
for await event in self.eventsClient.events() {
defer { count += 1 }
// Sends 100,000+ actions for large datasets
await send(.progress(Double(count) / Double(max)))
}
}
// ✅ Good: Throttle high-frequency actions
case .startButtonTapped:
return .run { send in
var count = 0
let max = await self.eventsClient.count()
let interval = max / 100 // Report at most 100 times
for await event in self.eventsClient.events() {
defer { count += 1 }
if count.isMultiple(of: interval) {
await send(.progress(Double(count) / Double(max)))
}
}
}
// ❌ Avoid: Slider sending actions for every change
Slider(value: store.$opacity, in: 0...1)
// ✅ Good: Slider with local state and onEditingChanged
@State private var opacity: Double = 0.5
Slider(value: $opacity, in: 0...1) {
store.send(.setOpacity(opacity))
}
```
## Store Scoping
```swift
// ✅ Good: Scope to stored child properties (performant)
ChildView(
store: store.scope(state: \.child, action: \.child)
)
// ❌ Avoid: Scope to computed properties (performance issue)
extension ParentFeature.State {
var computedChild: ChildFeature.State {
ChildFeature.State(
// Heavy computation here...
)
}
}
ChildView(
store: store.scope(state: \.computedChild, action: \.child) // ❌ Bad
)
// ✅ Good: Move computation to child view
ChildView(
store: store.scope(state: \.child, action: \.child)
)
// In ChildView, compute derived state locally
struct ChildView: View {
let store: StoreOf<ChildReducer>
var body: some View {
let computedValue = heavyComputation(store.state)
// Use computedValue in view
}
}
```
## Memory Management
```swift
// ✅ Good: Use weak capture in effects
case .loadData:
return .run { [weak self] send in
guard let self = self else { return }
let data = try await self.apiClient.fetchData()
await send(.didLoadData(.success(data)))
}
// ✅ Good: Clean up resources
case .view(.onDisappear):
return .concatenate(
.cancel(id: "data-loading"),
.cancel(id: "search")
)
// ✅ Good: Use proper cancellation IDs
case .loadData:
return .run { send in
for try await data in apiClient.dataStream() {
await send(.didReceiveData(data))
}
}
.cancellable(id: "data-stream")
```
## Async/Await Performance
```swift
// ✅ Good: Use structured concurrency
case .loadData:
return .run { send in
async let data = apiClient.fetchData()
async let metadata = apiClient.fetchMetadata()
let (dataResult, metadataResult) = await (data, metadata)
await send(.didLoadData(.success((dataResult, metadataResult))))
}
// ✅ Good: Handle cancellation properly
case .loadData:
return .run { send in
do {
let data = try await apiClient.fetchData()
if !Task.isCancelled {
await send(.didLoadData(.success(data)))
}
} catch {
if !Task.isCancelled {
await send(.didLoadData(.failure(error)))
}
}
}
.cancellable(id: "data-loading")
```
```