{"id":28474486,"url":"https://github.com/x-0o0/swiftui-tca-note","last_synced_at":"2025-09-14T23:02:03.656Z","repository":{"id":191890656,"uuid":"685531795","full_name":"x-0o0/SwiftUI-TCA-Note","owner":"x-0o0","description":"The Composable Architecture 공부 노트 (SwiftUI)","archived":false,"fork":false,"pushed_at":"2023-12-16T10:13:41.000Z","size":137,"stargazers_count":3,"open_issues_count":1,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2025-07-01T10:46:16.377Z","etag":null,"topics":["swift-composable-architecture","tca"],"latest_commit_sha":null,"homepage":"","language":"Swift","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/x-0o0.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null}},"created_at":"2023-08-31T12:49:48.000Z","updated_at":"2024-09-30T16:42:44.000Z","dependencies_parsed_at":"2023-09-26T00:53:06.629Z","dependency_job_id":"6eb44aef-8812-44dc-961c-20f8a845b369","html_url":"https://github.com/x-0o0/SwiftUI-TCA-Note","commit_stats":null,"previous_names":["jaesung-0o0/swiftui-tca-note","x-0o0/swiftui-tca-note"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/x-0o0/SwiftUI-TCA-Note","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/x-0o0%2FSwiftUI-TCA-Note","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/x-0o0%2FSwiftUI-TCA-Note/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/x-0o0%2FSwiftUI-TCA-Note/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/x-0o0%2FSwiftUI-TCA-Note/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/x-0o0","download_url":"https://codeload.github.com/x-0o0/SwiftUI-TCA-Note/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/x-0o0%2FSwiftUI-TCA-Note/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":275180200,"owners_count":25419066,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","status":"online","status_checked_at":"2025-09-14T02:00:10.474Z","response_time":75,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["swift-composable-architecture","tca"],"created_at":"2025-06-07T13:07:11.605Z","updated_at":"2025-09-14T23:02:03.600Z","avatar_url":"https://github.com/x-0o0.png","language":"Swift","funding_links":[],"categories":[],"sub_categories":[],"readme":"# SwiftUI-TCA-Note\nThe Composable Architecture 공부 노트 (SwiftUI)\n\n에피소드 별로 브랜치를 생성하여 관리하고 있습니다.\n\n\u003cimg width=\"75%\" alt=\"Screenshot 2023-09-01 at 2 00 58 AM\" src=\"https://github.com/jaesung-0o0/SwiftUI-TCA-Note/assets/53814741/e308fd18-c2c9-4a2e-9628-2f1874a3f94a\"\u003e\n\n# EPISODE. The Basics\n\n## Testing\n\n### MainActor\n```swift\n@MainActor\nfinal class TheBasicsTests: XCTestCase\n```\nXCTestCase 클래스에 MainActor 속성 추가하여 테스트들이 메인쓰레드에서 돌아가는 것을 보장.\n\n### TestStore\n```swift\nlet store = TestStore(initialState: CounterFeature.State()) {\n    CounterFeature()\n}\n```\n\n### `store.send(_:)`\n```swift\nawait store.send(.incrementButtonTapped) {\n    // $0: in-out piece of state. 액션 보내지기 전의 상태.\n    // 액션 전의 State의 값을 액션 후 기대되는 State에 맞춰 값으로 세팅\n    $0.count = 1\n}\n```\n\n## Dependencies\n\n### Task \u0026 Clock\n\n```swift\nTask.sleep(for: .second(1))\n```\nTask 를 쓰면 테스팅할때 아래와 같은 코드 작성시 sleep 시간만큼 정직하게 기다려야해서 테스트 시간이 오래 걸림.\n```swift\nTask.sleep(for: .second(1))\n\nawait store.receive(.timerTicked) {\n    $0.count = 1\n}\n\nTask.sleep(for: .second(1))\n\nawait store.receive(.timerTicked) {\n    $0.count = 1\n}\n```\n이를 개선하기 위해 TCA 에서 제공하는 clock을 사용\n```swift\n// Reducer\n@Dependency(\\.continuousClock) var clock\n\nvar body: some ReducerOf\u003cSelf\u003e { state, action in\n    // ...\n    self.clock.timer(interval: .seconds(1))\n}\n```\n테스트에서는 `advance(by:)` 를 사용해서 시간을 앞당기는 효과를 줄 수 있음. 타임워프\n```swift\nlet clock = TestClock()\n\nlet store = TestStore(initialState: CounterFeature.State()) {\n    CounterFeature()\n} withDependencies: {\n    $0.continuousClock = clock\n}\n\nawait clock.advance(by: .seconds(1))\n```\n위의 Task 를 사용한 코드를 아래와 같이 바꾸면 순식간에 테스트가 완료\n```swift\nawait clock.advance(by: .seconds(1))\n\nawait store.receive(.timerTicked) {\n    $0.count = 1\n}\n\nawait clock.advance(by: .seconds(1))\n\nawait store.receive(.timerTicked) {\n    $0.count = 1\n}\n```\n\n### Testing Error Case\n\n```swift\nlet store = TestStore(initialState: CounterFeature.State()) {\n    CounterFeature()\n} withDependencies: {\n    /// 테스트용 API Client  디펜던시\n    $0.numberFact.fetch = { _ in\n        struct SomeError: Error { }\n        throw SomeError()\n    }\n}\n\n/// 실패가 예상된다고 알리고  실패가 발생하면 테스트 성공 (강제로 통과)\nXCTExpectFailure()\n\nawait store.send(.getFactButtonTapped) {\n    /// 리듀서에서는 에러 처리 안하고 있음\n    /// 따라서 액션 보낼 때 아무 사이드 이펙트를 받지 못함\n    ///\n    /// 테스트는 통과 -\u003e isLoadingFact 이 계속 true -\u003e 무한 로딩\n    $0.isLoadingFact = true\n}\n```\n\n### UUID\n\n```swift\n// Reducer\n@Dependency(\\.uuid) var uuid\n\nvar body: some ReducerOf\u003cSelf\u003e { state, action in\n    // ...\n    let id = self.uuid()\n}\n```\n```swift\n// Testing method\nlet store = TestStore(\n    initialState: StandupFormFeature.State(\n        standup: Standup(\n        id: UUID(),\n        attendees: [\n            Attendee(id: UUID())\n        ]\n    )\n),\nreducer: { StandupFormFeature() },\nwithDependencies: {\n    /// `UUID(0)` 부터 시작해서 증가하는 방식으로 uuid 생성\n    $0.uuid = .incrementing\n    }\n)\n```\n\n### 디펜던시 만들기\n```swift\n/// 숫자에 대한 재밌는 사실을 가져오는 API Client\nstruct NumberFactClient {\n    var fetch: @Sendable (Int) async throws -\u003e String\n}\n\nextension NumberFactClient: DependencyKey {\n    static var liveValue = Self { number in\n        let (data, _) = try await URLSession.shared.data(\n            from: URL(\n                string: \"http://www.numbersapi.com/\\(number)\"\n            )!\n        )\n        return String(decoding: data, as: UTF8.self)\n    }\n}\n\nextension DependencyValues {\n    var numberFact: NumberFactClient {\n        get { self[NumberFactClient.self] }\n        set { self[NumberFactClient.self] = newValue }\n    }\n}\n```\n\n# EPISODE. Standups\n\n## Binding\n\n### Basics\n\n```swift\n// State\n@BindingState var standup: Standup\n```\n```swift\n// Action\ncase binding(BindingAction\u003cState\u003e)\n```\n```swift\n// Reducer\nvar body: some ReducerOf\u003cSelf\u003e {\n    BindingReducer() // 먼저 실행. 들어온 Binding action 을 다루고 BindingState 값을 업데이트\n\n    Reduce { state, action in\n    case .binding: // `BindingReducer` 에서 처리하기 때문에 `Reduce`에서는 처리할 필요 없음\n        return .none\n    // ...\n    }\n}\n```\n1. `BindingReducer` 가 가장 먼저 실행되어서 뷰에서 Binding action을 전송 시, state 변경을 위한 로직을 처리.\n디테일한 변경 사항은 `onChange(of:)` 를 사용하여 접근할 수 있다.\n```swift\nBindingReducer()\n    .onChange(of: \\.standup.title) { oldTitle, newTitle in\n        // ...\n    }\n```\n2. `BindingReducer` 가 Binding action 을 처리하기 때문에 `Reduce` 에서는 `.binding` 케이스의 액션에서는 아무것도 하지 않는다. \n\n```swift\n// View\nlet store: StoreOf\u003cStandupFeature\u003e\n\nvar body: some View {\n    WithViewStore(self.store, observe: { $0 }) { viewStore in\n        TextField(\"제목\", text: viewStore.$standup.title)\n    }\n}\n```\nPointfree 에서 지향하는 모습 (`WithViewStore` 가 사라지고 `Store` 를 `@State` 속성래퍼와 함께 사용)\n```swift\n// View (To-Be)\n@State var store: StoreOf\u003cStandupFeature\u003e\nVar body: some View {\n    TextField(\"제목\", text: $store.standup.title)\n}\n```\n\n### Focus\n\n```swift\n// State\n@BindingState var focus: Field?\n\nenum Field: Hashable {  // ⭐️ `Hashable` 준수 잊지 말것!\n    case attendee(Attendee.ID) // 어떤 `attendee`(참석자)에 focus 할지\n    case title\n}\n```\nfocus 에 사용하는 타입은 반드시 `Hashable` 를 준수하도록 해야함.\n\n```swift\n// Action\ncase binding(BindingAction\u003cState\u003e)\n```\n```swift\n// Reducer\nvar body: some ReducerOf\u003cSelf\u003e {\n    BindingReducer()\n\n    Reduce { state, action in\n        switch action {\n        case .addAttendeeButtonTapped:\n            // `state.standup` 에 새 참석자(newAttendee)를 추가한 다음...\n            state.focus = .attendee(newAttendee.id)\n            return .none\n        case let .deleteAttendees(atOffsets: indices):\n            // 1. 참석자 제거하고\n            // 2. `state.standup.attendees` 가 비어있으면 새 참석자 추가한 다음...\n            let index = min(removedItemIndex, lastAttendeeIndex)\n            state.focus = .attendee(state.standup.attendees[index].id)\n            return .none\n        }\n    }\n}\n```\n\n```swift\n// View\nlet store: StoreOf\u003cStandupFeature\u003e\n\n/// 1️⃣\n@FocusState var focus: StandupFeature.State.Field?\n\nvar body: some View {\n    WithViewStore(self.store, observe: { $0 }) { viewStore in\n        TextField(\"제목\", text: viewStore.$standup.title)\n            .focused(self.$focus, equals: .title) // 2️⃣ `focus` 값이 `.title` 이면 해당 텍스트필드에 초점 맞추기\n            .bind(viewStore.$focus, to: self.$focus) // 3️⃣\n    }\n}\n```\n\u003e **중요**\n\u003e\n\u003e SwiftUI 의 API 인, `.focused(_:equals:)`의 첫번째 파라미터 타입을 보면 `Binding\u003c...\u003e` 이 아니라 `FocusState\u003c...\u003e.Binding` 이다.\n\u003e\n\u003e `Binding` 과는 다른 타입이기 때문에 `Binding\u003c...\u003e` 타입인 `viewStore.$focus` 를 쓸 수가 없다. (Vanilla SwiftUI 에서도 동일하게 적용되는 내용)\n\u003e\n\u003e 그래서 SwiftUI 에서는 `@FocusState` 키워드를 사용한 변수를 사용하도록 한다.\n\n1️⃣\n`focused(_:equals:)`에 사용하기 위해 `@FocusState` 키워드 변수를 선언하고 타입을 StandupFeature 에 선언했던 focus 와 동일하게 맞춘다. \n```swift\n@FocusState var focus: StandupFeature.State.Field?\n```\n2️⃣\n선언한 `@FocusState` 변수를 `focused(_:equals:)` 에 사용\n```swift\nTextField(\"제목\", text: viewStore.$standup.title)\n    .focused(self.$focus, equals: .title)\n```\n3️⃣\n`@FocusState` 로 선언한 변수와 `viewStore.$focus` 는 같은 목적을 갖지만 다른 변수이므로 서로 올바른 값을 가질 수 있도록 연결해준다.\n```swift\n.bind(viewStore.$focus, to: self.$focus)\n```\n이렇게 `bind(_:to:)` 를 사용하면, 어느 한쪽에 `.onChange` 가 불릴거나 `.onAppear` 가 호출될 때 상대방의 값도 동일하게 바꿔준다.\n\n\n# EPISODE. Navigation\n\n## Presentation\n\n```swift\n// State\n\n@PresentationState var addStandup: StandupFormFeature.State?\n```\n\n```swift\n// Action\ncase addStandup(PresentationAction\u003cStandupFormFeature.Action\u003e)\n```\n`PresentationAction` 에는 2가지 작업 케이스가 있음.\n- `dismiss`\n- `presented`\n\n```swift\n// Reducer\n\nReduce { state, action in\n    switch action {\n        case .addButtonTapped:\n            // 네비게이션 활성화\n            state.addStandup = StandupFormFeature.State(standup: Standup.empty)\n            return .none\n            \n        case .saveStandupButtonTapped:\n            // 부모와 통신\n            guard let standup = state.addStateup?.standup else {\n                return .none\n            }\n            state.standups.apped(standup)\n            \n            // 네비게이션 비활성화\n            state.addStandup = nil\n            return .none\n        }\n    }\n    .ifLet(\\.$addStandup, action: /Action.addStandup) { // keyPath, casePath\n        StandupFormFeature()\n    }\n}\n```\n\n```swift\n// View\n\n/// `sheet(store:content:)` 사용\n.sheet(\n    /// `scope` 을 사용하여 `Store`의 범위를 **특정 부분에만 초점을** 맞출 수 있음\n    store: self.store.scope(\n        state: \\.$addStandup,       // keyPath\n        action: { .addStandup($0) } // closure\n    )\n) { store in\n    StandupForm(store: store)\n} \n/// **스와이프로 dismiss**하면 자동으로 `state.addStandup = nil` 이 됨\n```\n\n### scope\n\n`store.scope` 을 사용하여 `Store`의 범위를 **특정 부분에만 초점을** 맞출 수 있음\n\n### ifLet\n\n부모-자식 간의 Feature를 통합하여 서로간의 통신이 가능.\n\n예를 들어 부모가 언제 \"참석자 추가\" 버튼을 눌렀는지 알고 싶다면 아래와 같이 하면됨\n```swift\ncase .addStandup(.presented(.addAttendeeButtonTapped)):\n    // do something\n```\n\n## 비포괄 테스트\n\n### 포괄 방식 테스트\n```swift\n// 저장버튼 누른 경우\nawait store.send(.saveStandupButtonTapped) {\n    $0.addStandup = nil\n    $0.standups[0] = Standup(\n        id: UUID(0),\n        attendees: [Attendee(id: UUID(1))],\n        title: \"Point-Free Morning Sync\"\n    )\n}\n```\n포괄 방식 테스트는 $0 에 업데이트한 값이 액션 후 상태값과 전부 일치해야 테스트가 통과.\n👉 때로는 모든 데이터를 다보는 것이 아닌 특정 값의 업데이트만 확인하고 싶을 때가 있음\n👉 이 때 필요한게 비포괄 테스트\n\n### 비포괄 방식 테스트\n모든 댠계를 거치면 마지막에 `Standup` 객체가 컬렉션에 추가되는지만 확인하고 싶을 때(모든 작동 검증 없이 특정 결과만 확인하고 싶은 경우), 비포괄 테스트를 사용한다.\n\n비포괄적 테스트 모드를 하려면 `TestStore` 객체 생성 후 다음 코드를 추가한다.\n```swift\nstore.exhaustivity = .off\n```\n그러면 `send` 의 클로져의 argument (`$0`) 가 액션 전의 상태가 아닌 **액션 후의 상태**를 의미\n\n\u003e INFO:\n\u003e \n\u003e 만약 특정 결과만 확인하고 싶더라도 중간에 체크 안된 작동을 메세지로 받고 싶다면\n\u003e `store.exhaustivity = .off(showSkippedAssertions: true)`\n\n```swift\nawait store.send(.saveStandupButtonTapped) {\n-   $0.addStandup = nil // 🔴 제거해도 테스트 통과\n    $0.standups[0] = Standup(\n        id: UUID(0),\n        attendees: [Attendee(id: UUID(1))],\n        title: \"Point-Free Morning Sync\"\n    )\n}\n```\n\n## 네비게이션 방식 (Navigation Styles)\n\n### 트리 기반 네비게이션 (Tree-based navigation)\n네비게이션 state를 옵셔널로 모델링 하는 것\n- nil 이면, 해당 feature로 네비게이트 하지 않음을 나타냄\n- 값이 존재하면, 네비게이션을 활성화함을 나타냄.\nsheet 에서 사용하는 방식. \n\n### 스택 기반 네비게이션 (Stack-based navigation)\nstate의 1차원 배열로 네비게이션 스택을 모델링 하는 것\n드릴 다운 네비게이션을 위한 방식으로. 스택에 값을 추가하는 방식에 대응.\n\n\n# EPISODE. Stacks\n\n아래는 순수 SwiftUI 에서의 네비게이션 스택.\n\n```swift\nNavigationStack(path: Binding\u003c_\u003e, root: ()-\u003e _) {\n    ...\n}\n```\n이걸 TCA 로 다루는 법을 배우는 에피소드\n\n## 네비게이션 스택\n\n### 근본: App Feature\n\n네비게이션 스택에서 띄워질 모든 feature들을 통합시킴\n정리해보자면\n- StandupsListFeature 은 가장 root 이기 때문에 pop 될 일이 없음\n- StandupDetailView는 이번에 드릴 다운 네비게이션을 할 대상\n- 그리고 회의 녹화 기능 같은 앞으로 배울 기능도 드릴 다운 대상  \n\n```swift\nstruct AppFeature: Reducer {\n\n}\n```\n\n**State**\n```swift\n// AppFeature.struct\nstruct State {\n    var standupsList = StandupsListFeature.State() // 항상 root 로 갖고 있어서 절대로 팝 될 일이 없음\n}\n```\n**Action**\n```swift\nenum Action {\n    case standupsList(StandupsListFeature.Action)\n}\n```\n**Reducer/Body**\n```swift\nReduce { state, action in\n    switch action {\n    case .standupsList:\n        return .none\n    }\n}\n```\n`StandupsListFeature` 리듀서를 `AppFeature/body` 에 compose 할 방법을 이제 고민\n\n👉 이때 사용하는 것이 `Scope`\n\n`Scope` 은 부모로 부터 도메인 일부를 떼어내서 자식 리듀서를 실행\n\n```swift\nvar body: some ReducerOf\u003cSelf\u003e {\n    Scope(state: \\.standupsList, action: /Action.standupsList) { \n        StandupsListFeature() // 자식 리듀서 \n    }\n    \n    Reduce { ... }\n}\n```\n액션이 들어오면 `Scope` 의 child 리듀서에서 먼저 돌아가고 그 다음에 `AppFeature` 코어 로직인 `Reduce` 가 실행됨\n\n**Store**\n```swift\n// AppView.struct\nlet store: StoreOf\u003cAppFeature\u003e // 1️⃣ full parent domain of app feature\n\nvar body: some View {\n    NavigationStack {\n        StandupsListView(\n            store: self.store.scope( // 2️⃣ to pluck out the domain we're interested in, scope on the store\n                state: \\.standupsList, \n                action: { .standupsList($0) \n            )\n        )\n    }\n}\n```\n\n### 푸시\n\n푸시를 위한 TCA 도구가 있음.\n\n**State**\n현재 어떤 feature가 스택에서 돌아가는지를 나타내기 위해 `StackState` 라는 것을 사용한 collection 변수를 선언\n```swift\nstruct State {\n    var path = StackState\u003cPath.State\u003e()\n    // ...\n}\n```\n**Action**\n```swift\nenum Action {\n    case path(StackAction\u003cPath.state, Path.Action\u003e)\n    // ...\n}\n```\n`StackAction` 는 PresentationAction 과 동일\n- `element(id:action:)` \n    - 다루고자 하는 스택 요소의 `id` 와 `action` 을 사용해서 스택의 자식 요소에 어떤 일이 일어나는지 나타낼 수 있음\n- `popFrom(id:)`\n    - 어떤 `id` 로 부터 팝\n- `push(id:state)`\n\n**Reducer/body**\n```swift\nReduce { state, action in\n    switch action {\n    case .path: // 1️⃣\n        return .none\n    }\n}\n.forEach(\\.path, action: /Action.path) { // 2️⃣\n    Path()\n}\n```\n- 1️⃣ `.path` 케이스에서 `.popFrom(id:)` 같은 액션을 전달해서 스택 요소를 팝할 수 있음\n- 2️⃣ `.forEach(_:action:destination:)`\n    - `destination`에는 모든 destination 을 캡슐화한 리듀서를 사용\n    - `$` 기호를 안쓰는 건 `StackState`가 프로퍼티 래퍼가 아니기 때문\n\n**Store**\n```swift\nvar body: some View {\n    NavigationStackStore(\n        self.store.scope(state: \\.path, action: { .path($0) })  // 1️⃣\n    ) {\n        // 2️⃣ root\n        StandupsListView(...)\n    } destination: { state in // 3️⃣\n        switch state {\n        case .detail:\n            CaseLet(    // 4️⃣\n                /AppFeature.Path.State.detail,\n                action: AppFeature.Path.Action.detail,\n                then: { StandupDetailView(store: $0) }\n            )\n        }\n    }\n}\n```\n- NavigationStackStore 에서는 3가지를 다룸\n    - 1️⃣ `store`: 네비게이션을 돌리기 위한 스택의 상태와 액션에 맞춘 store를 전달. 즉 `store.scope` 사용\n    - 2️⃣ `root`: root 뷰\n    - 3️⃣ `destination`: 스택에 푸시될 수 있는 모든 뷰의 destination\n        - 4️⃣ destination 뷰에 store 를 전달할 때는 `scope` 보다는 `CaseLet` 을 사용할 것. `scope` 은 복잡하기 때문\n\n**NavigationStackStore/destination**\n```swift\n// 미래의 TCA가 가질 모습: CaseLet 제거 하고 scope 사용하기\ndestination: { store in\n    switch store.state {\n    case .detail:\n        if let store = store.scope(state: \\.detail, action: { .detail($0) }) {\n            StandupDetailView(store: store)\n        }\n    }\n}\n```\n\n스택은 많은 스크린 타입을 다룰 수 있다. 그래야 Detail 스크린 말고도, 녹화 스크린, 지난 미팅 기록 스크린 으로도 드릴 다운 할 수 있다.\n\n따라서 다양한 위치들을 enum 을 사용해서 모델링 해야하고 각 스택의 대상을 단일 기능으로 패키징 하기 위해 `Path` 라는 새로운 리듀서를 정의.\n\n즉, Path 를 위한 State가 enum 인 리듀서를 생성\n```swift\nstruct Path: Reducer {\n    enum State {\n        case detail(StandupDetailFeature.State)\n        // 그 외의 destination\n    }\n    \n    enum Action {\n        case detail(StandupDetailFeature.Action)\n    }\n    \n    var body: some ReducerOf\u003cSelf\u003e {\n        // Scope 을 사용해서 모든 destination 의 리듀서를 compose 해야한다.\n        Scope(state: /State.detail, action: /Action.detail) {\n            StandupDetailFeature()\n        }\n    }\n}\n```\n앞으로 푸시해야할 새 feature가 생기면 `Path` 리듀서의 `State` 와 `Action` 에 `case` 를 추가하고 `body` 에 `Scope` 를 추가.\n```swift\n// Path.State.enum\n+   case recordMeeting(RecordMeetingFeature.State)\n```\n```swift\n// Path.Action.enum\n+   case recordMeeting(RecordMeetingFeature.State)\n```\n```swift\n// Path/body\n+   Scope(state: /State.recordMeeting, action: /Action.recordMeeting) {\n        RecordMeetingFeature()\n    }\n}\n```\n\n**푸시 액션**\n```swift\n// StandupsListView/body\n\nNavigationLink(\n    state: AppFeature.Path.State.detail(\n        StandupDetailFeature.State(standup: standup)\n    )\n) {\n    CardView(standup: standup)\n}\n```\n`NavigationLink(state:)` 라는 새로운 생성자를 사용해서 `AppFeature.Path` 스택 상태를 `detail` 로 변경할 수 있음\n\n### 앱 실행시 즉각 네비게이션 실행하기\n**StandupsApp**\n```swift\nvar body: some Scene {\n    WindowGroup {\n        var editedStandup = Standup.mock\n        let _ = editedStandup.title += \"오전 싱크\"\n        \n        AppView(\n            store: Store(\n                initialState: AppFeature.State(\n                    // 1️⃣ path 지정하여 푸시하기\n                    path: StackState([\n                        .detail(\n                            StandupDetailFeature.State(\n                                standup: .mocl,\n                                // 2️⃣ `editStandup` 값 넣어서 present sheet\n                                editStandup: StandupFormFeature.State(\n                                    focus: .attendee(editedStandup.atteendees[3].id),\n                                    standup: editiedStandup\n                                )\n                            )\n                        )\n                    ]),\n                    standupsList: ...\n                ),\n                reducer: { ... }\n            )\n        )\n    }\n}\n```\n- 1️⃣ path 지정해서 Detail 뷰로 드릴 다운 하기.\n- 2️⃣ `editStandup` 값 넣어서 Form 뷰 present 하기\n\n### Detail 뷰에서 Root 뷰로 신호 전달하기\n\n**AppFeature/body**\n```swift\nReduce { state, action in\n    switch action {\n    \n    case let .path(.popFrom(id: id)):   // 1️⃣\n        // 2️⃣\n        guard case let .some(.detail(detailState)) = state.path[id: id] else {\n            return .none\n        }\n        // 3️⃣\n        state.standupsList.standups[id: detailState.standup.id] = detailState.standup\n        return .none\n    }\n    // ...\n}\n```\n\n- 1️⃣ `popFrom`: 뒤로가기 버튼을 누를 때 호출 된다. 여기서 전달받은 상태변화를 root 로 전달해주면 된다.\n- 2️⃣ 만약 pop 하는 상태가 `detail` 이면 해당 상태를 `detailState` 로 잡아서\n- 3️⃣ `detailState` 의 스탠드업 ID 에 해당하는 스탠드업을 `standupsList` 에서 가져와서 `detailState` 의 변경된 스탠드업으로 교체\n- 하지만 root 로 돌아오는 애니메이션이 완전히 종료될 때까지 root 의 상태가 바뀌지 않는다.\n    - 이 때는 `popFrom` 말고 `element(id:action)` 에서 `.saveStanupButtonTapped` 같은 액션을 처리하는 방식으로 하면 된다.\n    \n```swift\nReduce { state, action in\n    switch action {\n    \n    case let .path(.element(id: id, action: .detail(.saveStandupButtonTapped))):\n        guard case let .some(.detail(detailState)) = state.path[id: id] else {\n            return .none\n        }\n        state.standupsList.standups[id: detailState.standup.id] = detailState.standup\n        return .none\n    }\n    // ...\n}\n```\n\n- 그러나, 부모 도메인이 자식 도메인을 가로채기 하는 것은 이상적이지 않음\n    - 부모 도메인이 로직을 올바르게 실행하기 위해서는 자식 도메인에서 무슨일이 일어나는 지를 너무 많이 알아야하기 때문\n    - 이 때는 `delegate` 액션을 사용하는 것이 좋다.\n\n### 델리게이트 액션\n\n**Action**\n```swift\nenum Action {\n    // Delegate\n    case delegate(Delegate)\n        \n    enum Delegate {\n        // 1️⃣\n        case standupUpdated(Standup)\n    }\n    \n    // ...\n}\n```\n- 1️⃣ 부모 도메인에게 얘기하고자 하는 액션을 `Delegate` enum 에 적어주면 됨\n- 그러면 부모 도메인이 해당 Delegate 액션을 listen 하고 있다가 정보가 들어오면 필요한 동작을 수행하게 됨\n\n**Reducer/body**\n```swift\nvar body: some ReducerOf\u003cSelf\u003e {\n    Reducer { state, action in\n    case .delegate:\n        // 1️⃣\n        return .none\n        \n    case .saveStandupButtonTapped:\n        // state.standup 업데이트\n        \n        // 2️⃣\n        return .send(.delegate(.standupUpdated(state.standup)))\n    }\n}\n```\n- 1️⃣ 자식 도메인은 절대로 delegate 액션에 대해서 아무것도 하지 말아야 한다.\n- 2️⃣ `send(_:)` 를 사용해서 `delegate` 액션 전달\n\n⭐️ 하지만 더 좋은 방법은 `state.standup` 의 변화를 감지하면 delegate 액션을 전달하는 것이다.\n\n**Reducer/body**\n\n```swift\nvar body: some ReducerOf\u003cSelf\u003e {\n    Reducer { state, action in\n    case .delegate:\n        return .none\n        \n    case .saveStandupButtonTapped:\n        return .none\n    }\n    .onChange(of: \\.standup) { oldValue, newValue in\n        // 1️⃣\n        Reduce { state, action in\n            .send(.delegate(.standupUpdated(newValue)))\n        }\n    }\n}\n```\n- 1️⃣ 커스텀 리듀서\n\n**부모Feature/body**\n```swift\nReduce { state, action in\n    case let .path(.element(id: _, action: .detail(.delegate(action)))):\n        switch action {\n        case let .standupUpdated(standup):\n            state.standupsList.standups[id: standup.id] = standup\n            return .none\n        }\n    }\n}\n```\n\n## Alert\n\n다음 API 를 사용하여 Alert 기능을 구현한다.\n- State: `PresentationState`, `AlertState` \n- Action: `PresentationAlert`\n- Reducer: `AlertState`, `TextState`, `ButtonState`, `ifLet`\n- View: `alert(store:)` \n\n**Action**\n\n```swift\nenum Alert {\n    case confirmDelete\n}\ncase alert(PresentationAlert\u003cAlert\u003e)\n```\n\n**State**\n\n```swift\n@PresentationState var alert: AlertState\u003cAction.Alert\u003e?\n```\n\n**Reducer/body**\n\n```swift\nReduce { state, action in\n    switch action{\n    // 삭제 버튼을 눌렀을 때\n    case .deleteButtonTapped:\n        state.alert = AlertState {\n            // title\n            TextState(\"정말 삭제하시겠습니까?\")\n        } actions {\n            ButtonState(role: .destructive, action: .confirmDeletion) {\n                TextState(\"삭제\")\n            }\n        }\n        return .none\n    \n    case .alert(.presented(.confirmDeletion):\n        return .none\n        \n    case .alert(.dismiss):\n        return .none\n    }\n    .ifLet(\\.$alert, action: /Action.alert)\n}\n```\n\n**View**\n\n```swift\n.alert(\n    store: self.store.scope(\n        state: \\.$alert,\n        action: { .alert($0) }\n    )\n)\n```\n\n## Multiple navigation\n\u003e **문제**: 너무 많이 @PresentationState 의 옵셔널 타입 프로퍼티가 계속 늘어나고 한번에 관리해야한다면?\n\n```swift\nstate.editStandup = ...\nstate.alert = AlertState(...)\n```\n\n**해결책**: `enum` 을 사용하자 -\u003e 열거형 네비게이션 `// 다음 에피소드`\n\n\n# EPISODE. Domain Modeling\n\n## 개요\n\n네비게이션 대상이 4개라면 다음과 같이 실제 가능한 경우의 수는 5가지가 있다.\n| A | B | C | D | 설명 |\n| --- | --- | --- | --- | --- |\n| `nil` | `nil` | `nil` | `nil` | 기본 상태 |\n| `non-nil` | `nil` | `nil` | `nil` | A 가 보여짐 |\n| `nil` | `non-nil` | `nil` | `nil` | B 가 보여짐 |\n| `nil` | `nil` | `non-nil` | `nil` | C 가 보여짐 |\n| `nil` | `nil` | `nil` | `non-nil` | D 가 보여짐 |\n\n하지만, A, B, C, D 가 모두 옵셔널 타입이기 때문에 두개이상이 `non-nil` 이거나 전부다 `non-nil` 일 수도 있는 상황이 발생한다.\n\n총 경우의 수는 16가지고 여기서 70% 가 올바르지 않은 상태인 것이다.\n\n이에 적합한 도구가 `enum` \n\n## Destination\n\n```swift\n// StandupDetailFeature\n\nstruct State: Equatable {\n    @PresentationState var alert: AlertState\u003cAction.Alert\u003e?\n    @PresentationState var editStandup: StandupFormFeature.State?\n```\n\n`@PresentationState` 로 선언되었던 변수들을 `enum` 으로 관리하기 위해 앞서 스택 기반 네비게이션에서 다뤘던 `Path` 와 동일한 형태로\n`Destination` 이라는 reducer 를 선언한다.\n\n```swift\nstruct Destination: Reducer\n```\n### State\n```swift\nenum State: Equatable {\n    case alert(AlertState\u003cAction.Alert\u003e)\n    case editStandup(StandupFormFeature.State)\n}\n```\n### Action\n```swift\nenum Action: Equatable {\n    case alert(Alert)\n    case editStandup(StandupFormFeature.Action)\n\n    enum Alert {\n        case confirmDeletion\n    }\n}\n```\n### Reducer/body\n```swift\nvar body: some ReducerOf\u003cSelf\u003e {\n    Scope(state: /State.editStandup, action: /Action.editStandup) {\n        StandupFormFeature()\n    }\n}\n```\n\n### 부모 리듀서\n```swift\n// StandupDetailFeature\n\nstruct State: Equatable {\n    @PresentationState var destination: Destination.State?\n//    @PresentationState var alert: AlertState\u003cAction.Alert\u003e?\n//    @PresentationState var editStandup: StandupFormFeature.State?\n}\n\nenum Action: Equatable {\n    case destination(PresentationAction\u003cDestination.Action\u003e)\n//    case alert(PresentationAction\u003cAlert\u003e)\n//    case editStandup(PresentationAction\u003cStandupFormFeature.Action\u003e)\n}\n\nvar body: some ReducerOf\u003cSelf\u003e {\n    Reduce { state, action in\n        switch action {\n        case .cancelEditStandupButtonTapped:\n            state.destination = nil\n            return .none\n\n        case .deleteButtonTapped:\n            let alertState = AlertState(...)\n            state.destination = .alert(alertState)\n            return .none\n\n        case .editButtonTapped:\n            let standupForm = StandupFormFeature.State(standup: state.standup)\n            state.destination = .editStandup(standupForm)\n            return .none\n\n        case .saveStandupButtonTapped:\n            guard case let .editStandup(standupForm) = state.destination else { return .none }\n            state.standup = standupForm.standup\n            state.destination = nil\n            return .none\n\n        case .destination:\n            return .none\n        }\n    }\n    .ifLet(\\.$destination, action: /Action.destination) {\n        Destination()\n    }\n}\n```\n\n### View\n```swift\n// alert 경우\n.alert(\n    store: self.store.scope(state: \\.$destination, action: {  destination($0) }),\n    state: /StandupDetailFeature.Destination.State.alert,\n    action: StandupDetailFeature.Destination.Action.alert\n)\n// sheet 경우\n.sheet(\n    store: self.store.scope(state: \\.$destination, action: { .destination($0) }),\n    state: /StandupDetailFeature.Destination.State.editStandup,\n    action: StandupDetailFeature.Destination.Action.editStandup\n) { store in\n    ...\n}\n```\n\n## Alert: Dismiss \u0026 델리게이트\n\nSwiftUI에서의 `@Environment(\\.dismiss) var dismiss` 처럼 TCA 에도 이에 해당하는 디펜던시가 있다.\n\n```swift\n@Dependency(\\.dismiss) var dismiss\n```\n\n`ifLet` 이나 `forEach` 를 사용하여 띄워진 자식 리듀서에 dismiss 행동이 적용된다.\n\n```swift\n// 현재 스탠드업 제거 액션\ncase .destination(.presented(.alert(.confirmDeletion))):\n    return .run { _ in\n        await self.dismiss()\n    }\n```\n\n### 부모와 소통\n\n**Action/Delegate**\n\n```swift\nenum Action: Equatable {\n    case delegate(Delegate)\n    enum Delegate: Equatable {\n        case deleteStandup(id: Standup.ID)\n    }\n}\n```\n\n**Reducer/body**\n\n```swift\ncase .destination(.presented(.alert(.confirmDeletion))):\n    return .run { [id = state.standup.id] _ in\n        await send(.delegate(.deleteStandup(id: id)))\n        await self.dismiss()\n    } \n```\n\n**ParentReducer/body**\n\n부모 리듀서에서 Delegate 액션 처리\n\n```swift\ncase let .path(.element(id: _, action: .detail(.delegate(action)))):\n    switch action {\n    case let .deleteStandup(id: id):\n        state.standupsList.standups.remove(id: id)\n        return .none\n    }\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fx-0o0%2Fswiftui-tca-note","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fx-0o0%2Fswiftui-tca-note","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fx-0o0%2Fswiftui-tca-note/lists"}