Guide TCA (The Composable Architecture) development for VoiLog iOS app including @ObservableState, @ViewAction, delegate patterns, and TestStore testing.
VoiLog uses modern TCA patterns with @ObservableState and @ViewAction.
Start with: /ios/VoiLog/Template/FeatureTemplate.swift
Primary locations (PREFERRED for modifications):
/ios/VoiLog/Recording/RecordingFeature.swift/ios/VoiLog/Playback/PlaybackFeature.swift/ios/VoiLog/DebugMode/VoiceAppFeature.swift (VoiceAppFeature)Legacy location (AVOID modifications):
/ios/VoiLog/Voice/ - Legacy production codeFor new features or modifications:
/ios/VoiLog/Recording, /Playback, /DebugMode)VoiceAppFeatureNever modify:
/ios/VoiLog/Voice/ directory (Legacy)@Reducer
struct MyFeature {
@ObservableState
struct State: Equatable {
var isLoading = false
// Add your state properties
}
enum Action: ViewAction {
case view(View)
case delegate(Delegate)
enum View {
case onAppear
case buttonTapped
}
enum Delegate {
case completed(Result)
}
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .view(.onAppear):
// Implementation
return .none
case .view(.buttonTapped):
// Send delegate action to parent
return .send(.delegate(.completed(.success)))
case .delegate:
return .none
}
}
}
}
For features that need tab switching:
// In VoiceAppFeature
case .recording(.delegate(.recordingCompleted)):
state.selectedTab = 1 // Switch to playback tab
return .send(.playback(.reloadData))
See "Testing Patterns" section below for comprehensive test examples.
import ComposableArchitecture
import Testing
@Test
func testBasicAction() async {
let store = TestStore(initialState: MyFeature.State()) {
MyFeature()
}
// Send action and verify state changes
await store.send(.view(.buttonTapped)) {
$0.isLoading = true
}
// Verify delegate action received
await store.receive(.delegate(.completed))
}
@Test
func testStateChange() async {
let store = TestStore(initialState: RecordingFeature.State()) {
RecordingFeature()
}
await store.send(.view(.recordButtonTapped)) {
$0.recordingState = .recording
$0.isRecordButtonEnabled = false
}
}
@Test
func testDelegateNotification() async {
let store = TestStore(
initialState: RecordingFeature.State(recordingState: .recording)
) {
RecordingFeature()
}
await store.send(.view(.stopButtonTapped)) {
$0.recordingState = .stopped
}
await store.receive(.delegate(.recordingCompleted))
}
@Test
func testWithRepository() async {
let mockMemo = VoiceMemo(
id: UUID(),
title: "Test Memo",
duration: 60.0
)
let store = TestStore(initialState: PlaybackFeature.State()) {
PlaybackFeature()
} withDependencies: {
$0.voiceMemoRepository = .mock(
fetchAll: { [mockMemo] }
)
}
await store.send(.view(.onAppear))
await store.receive(.memosLoaded([mockMemo])) {
$0.memos = [mockMemo]
}
}
@Test
func testAsyncOperation() async {
let store = TestStore(initialState: MyFeature.State()) {
MyFeature()
}
await store.send(.view(.loadData)) {
$0.isLoading = true
}
await store.receive(.dataLoadComplete(result: .success(data))) {
$0.isLoading = false
$0.data = data
}
}
@Test
func testWithTimer() async {
let clock = TestClock()
let store = TestStore(initialState: MyFeature.State()) {
MyFeature()
} withDependencies: {
$0.continuousClock = clock
}
await store.send(.view(.startTimer)) {
$0.isTimerRunning = true
}
await clock.advance(by: .seconds(1))
await store.receive(.timerTicked) {
$0.elapsedTime = 1
}
}
@Test
func testErrorHandling() async {
let store = TestStore(initialState: MyFeature.State()) {
MyFeature()
} withDependencies: {
$0.voiceMemoRepository = .mock(
fetchAll: { throw TestError.networkError }
)
}
await store.send(.view(.loadData))
await store.receive(.loadFailed(TestError.networkError)) {
$0.errorMessage = "Network error"
}
}
xcodebuild test \
-project ios/VoiLog.xcodeproj \
-scheme VoiLogTests \
-destination 'platform=iOS Simulator,name=iPhone 15'
xcodebuild test \
-project ios/VoiLog.xcodeproj \
-scheme VoiLogTests \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-only-testing:VoiLogTests/PlaylistListFeatureTests
// ❌ Bad: State changes but not verified
await store.send(.view(.buttonTapped))
// ✅ Good: All state changes verified
await store.send(.view(.buttonTapped)) {
$0.isLoading = true
$0.buttonTitle = "Loading..."
}
// ❌ Bad: Delegate action sent but not verified
await store.send(.view(.complete))
// ✅ Good: All received actions verified
await store.send(.view(.complete))
await store.receive(.delegate(.completed))
// ❌ Bad: Wrong reception order
await store.receive(.second)
await store.receive(.first)
// ✅ Good: Correct order
await store.receive(.first)
await store.receive(.second)
@Dependency(\.voiceMemoRepository) var repository
// In reducer
case .view(.saveData):
return .run { [data = state.data] send in
try await repository.save(data)
await send(.delegate(.saved))
}
See /ios/VoiLog/DebugMode/VoiceAppFeature.swift for reference:
selectedTab statereloadData action to target tabCause: Forgot @ObservableState macro
Solution: Add @ObservableState to State struct
Cause: Using old WithViewStore pattern
Solution: Use @ViewAction pattern instead
Cause: State mutation block provided but state didn't change Solution: Either remove the mutation block or ensure state actually changes
// ❌ Bad: Expects state change but none occurs
await store.send(.view(.noOp)) {
$0.value = newValue // But reducer doesn't change this
}
// ✅ Good: No mutation block if no state change
await store.send(.view(.noOp))
Cause: Reducer sent an action that wasn't verified in test
Solution: Add await store.receive() for all emitted actions
// ❌ Bad: Missing receive
await store.send(.view(.load))
// Test fails: Unexpected .loadComplete action
// ✅ Good: Verify all received actions
await store.send(.view(.load))
await store.receive(.loadComplete)
Cause: Actions received in wrong order or not at all Solution: Verify actions in the exact order they're emitted
// ❌ Bad: Wrong order
await store.receive(.second)
await store.receive(.first)
// ✅ Good: Correct order
await store.receive(.first)
await store.receive(.second)
Cause: Waiting for an action that never comes Solution:
store.exhaustivity = .off for debugging (not recommended for final tests)For detailed examples:
/ios/VoiLog/Recording/RecordingFeature.swift/ios/VoiLog/Playback/PlaybackFeature.swift/ios/VoiLog/DebugMode/VoiceAppFeature.swift