← TCA 14일 마스터 5 / 14

Day 5 · 조립 ① — Scope와 부모·자식

큰 기능을 작은 리듀서로 나눠 부모에 끼우고, 자식이 위로 말하는 법과 있다 없다 하는 자식을 다룬다.
진행률
0 / 0 완료
@Observable로 화면을 만들 때 큰 모델은 작은 모델을 프로퍼티로 품었다. 자식이 부모에게 뭔가 알리려면 콜백 클로저를 꽂았고, 자식을 없앨 땐 자식이 돌리던 Task를 직접 취소했다. TCA에서는 이 세 가지를 리듀서 조립 도구가 맡는다. 오늘은 Scope로 자식을 끼우고, store.scope로 자식 뷰에 스토어를 넘기고, delegate 액션으로 위로 말하고, ifLet으로 있다 없다 하는 자식을 다룬다.

오늘의 목표 · 30분

  • 부모 State·Action에 자식 도메인을 넣고 Scope(\.child, action: \.child)로 자식 리듀서를 끼운다.
  • store.scope(\.child, action: \.child)로 자식 뷰에 스토어를 넘기고, 액션이 루트까지 갔다 오는 순서를 그림으로 설명한다.
  • 부모가 자식 내부 액션을 엿보는 코드를 찾아 delegate 액션으로 고친다.
  • 옵셔널 자식을 ifLet으로 붙이고, 로직을 "공유 액션" 대신 메서드로 뽑는다.

1. Scope — 자식 도메인을 부모에 끼운다 🔥 핵심

기능이 커지면 하나의 리듀서에 모든 case를 넣을 수 없다. TCA는 기능을 작은 리듀서로 나누고 부모가 그 도메인을 그대로 품게 한다. 준비물은 세 가지다.

먼저 SwiftUI 코스에서 하던 방식이다. 부모 모델이 자식 모델 인스턴스를 들고 있으면 끝난다(SwiftUI 코스 Day 5).

SwiftUI(@Observable) — 부모 모델이 자식 모델 참조를 품는다
@Observable
final class CounterModel {
  var count = 0
  func incrementButtonTapped() { count += 1 }
  func decrementButtonTapped() { count -= 1 }
}

@Observable
final class TwoCountersModel {
  let counter1 = CounterModel()   // 자식은 참조 — 누가 바꿨는지는 추적 안 됨
  let counter2 = CounterModel()
}

TCA에서는 자식이 값(State)과 액션 목록으로 들어간다. 아래는 공식 케이스 스터디 TwoCounters와 같은 구조다.

TCA — Counter를 두 번 끼운 TwoCounters
@Reducer
struct Counter {
  @ObservableState
  struct State: Equatable {
    var count = 0
  }
  enum Action {
    case decrementButtonTapped
    case incrementButtonTapped
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .decrementButtonTapped:
        state.count -= 1
        return .none
      case .incrementButtonTapped:
        state.count += 1
        return .none
      }
    }
  }
}

@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 ReducerOf<Self> {
    Scope(\.counter1, action: \.counter1) { Counter() }
    Scope(\.counter2, action: \.counter2) { Counter() }
  }
}
TwoCounters (부모) State counter1 counter2 (부모 고유 필드) Action .counter1(…) .counter2(…) (부모 고유 케이스) Counter() ① State·Action 한 벌만 안다 Counter() ② 부모가 누군지 모른다 Scope ① Scope ② key path로 State 조각을, case key path로 Action 조각을 잘라 자식에게 건넨다
Scope는 부모 도메인에서 자식 몫만 잘라 자식 리듀서를 돌린다. 자식 리듀서는 자기 State·Action만 알고 부모를 모른다 — 그래서 따로 테스트하고 재사용할 수 있다.
📝 TCA 문서 원문

"When run, it will intercept all child actions sent and feed them to the child reducer so that it can update the parent state and execute effects." — TCA 소스 · Scope

풀이: Scope는 자식 액션만 골라 자식 리듀서에 먹인다. 자식이 바꾼 상태는 부모 State 안의 그 필드에 그대로 쓰이고, 자식 이펙트가 보낸 액션은 다시 .counter1(…)로 감싸져 돌아온다.

⚠️ 라벨 표기: Scope(state:action:)은 옛 표기

1.26.2 소스에서 Scope(state:action:)과 store.scope(state:action:)은 init(_:action:_:) / scope(_:action:)으로 이름이 바뀐(renamed) 옛 표기다. 지금은 경고가 나지 않지만(deprecated: 9999), 2.0 대비 trait(ComposableArchitecture2Deprecations)를 켜면 경고가 뜬다(소스 기준). 공식 예제는 모두 라벨 없는 쪽을 쓴다. 회사 코드에서 state:를 보면 같은 것으로 읽으면 된다.

할 일SwiftUI(@Observable)TCA
자식을 품기let counter1 = CounterModel()var counter1 = Counter.State() + case counter1(Counter.Action)
자식 로직 연결자동(메서드 직접 호출)Scope(\.counter1, action: \.counter1) { Counter() } 한 줄
변경 경로 추적아무 곳에서나 counter1.count = 5 가능모든 변경이 부모 Action을 거친다 — 로그·테스트 한 곳
대가코드가 짧다State·Action·Scope 세 곳을 매번 손댄다

2. store.scope — 자식 뷰에 스토어를 넘긴다 🔥 핵심

리듀서를 합쳤으면 뷰도 나눈다. 자식 뷰 CounterView는 StoreOf<Counter>만 받는다. 부모 뷰는 자기 스토어에서 store.scope(\.counter1, action: \.counter1)로 자식 스토어를 잘라 넘긴다. 이름은 비슷하지만 층이 다르다 — Scope는 로직을, store.scope는 뷰에 줄 스토어를 자른다.

TCA — 부모 뷰가 자식 스토어를 잘라 넘긴다
struct CounterView: View {
  let store: StoreOf<Counter>       // 부모를 모른다
  var body: some View {
    HStack {
      Button("−") { store.send(.decrementButtonTapped) }
      Text("\(store.count)").monospacedDigit()
      Button("+") { store.send(.incrementButtonTapped) }
    }
  }
}

struct TwoCountersView: View {
  let store: StoreOf<TwoCounters>
  var body: some View {
    Form {
      CounterView(store: store.scope(\.counter1, action: \.counter1))
      CounterView(store: store.scope(\.counter2, action: \.counter2))
    }
    .buttonStyle(.borderless)
  }
}

#Preview {
  TwoCountersView(store: Store(initialState: TwoCounters.State()) { TwoCounters() })
}
자식 스토어는 상태를 따로 들지 않고 루트 스토어를 가리킨다 ① 자식 뷰 send(.increment ButtonTapped) ② 자식 스토어 .counter1(…)로 감싸 루트로 ③ Scope 자식 액션 꺼내 Counter 실행 ④ 부모 Reduce 바뀐 count를 이미 본다 ⑤ 뷰 갱신 count를 읽은 뷰만 다시 그림 body에 적힌 순서 = 실행 순서 — Scope가 위에 있으면 자식이 먼저 돈다 두 리듀서가 돌려준 Effect는 합쳐(merge) 실행된다
③→④ 순서는 body의 위아래 순서로 정해진다. 부모가 자식의 결과를 보고 반응하려면 Scope를 부모 Reduce 위에 둔다.

순서가 body에 적힌 대로인 것은 ReducerBuilder 소스에서 확인된다. 두 리듀서를 r0._reduce → r1._reduce 차례로 부르고 이펙트는 merge한다(소스 기준).

⚠️ 저장 프로퍼티로만 scope한다

1.5부터 자식 스토어는 루트를 참조만 하고, 상태를 읽을 때마다 루트 → 자식 변환을 다시 한다. 그래서 무거운 계산 프로퍼티로 store.scope(\.computedChild, …)를 하면 그 계산이 읽을 때마다 반복된다. 문서 권고: 스코프는 저장 프로퍼티만 따라가고, 계산은 잎(leaf) 뷰 쪽으로 민다(TCA 문서 · Performance · Store scoping).

SwiftUI(@Observable)TCA
자식 뷰에 넘기는 것자식 모델 참조 CounterModelView(model: m.counter1)잘라낸 스토어 store.scope(\.counter1, action: \.counter1)
자식 뷰가 할 수 있는 일모델의 모든 프로퍼티·메서드자기 State 읽기 + 자기 Action 보내기뿐
갱신 범위둘 다 Observation — body가 읽은 프로퍼티만 추적

3. 부모가 자식 액션에 반응하기 — delegate 액션 🔥 핵심

부모 Action에 case edit(ProfileEdit.Action)이 있으니, 부모 Reduce는 자식의 모든 액션을 switch로 볼 수 있다. 이게 편하지만 함정이다. 부모가 자식의 버튼 액션을 보고 "저장 버튼이니 이름을 반영하자"고 짐작하면, 자식이 검증을 추가하거나 버튼 이름을 바꿀 때 부모가 조용히 틀린다.

❌ 부모가 자식의 내부 액션을 엿본다 — 빈 문자열 검증을 우회한다
// 부모 Reduce 안
case .edit(.saveButtonTapped):
  state.name = state.edit.draft   // 자식이 "저장 불가"라 판단해도 부모는 모른다
  return .none
📝 TCA 문서 원문

"A better pattern is to use so-called "delegate actions" for the child feature to directly tell the parent what it wants done." — Meet TCA 튜토리얼 · Child-to-parent communication

풀이: 자식이 "부모님, 이걸 해 주세요"를 액션으로 명시한다. 부모는 그 목록만 듣는다.

✅ 자식 — 부모에게 할 요청만 Delegate enum에 모은다
@Reducer
struct ProfileEdit {
  @ObservableState
  struct State: Equatable {
    var draft = ""
  }
  enum Action {
    case draftChanged(String)
    case saveButtonTapped
    case delegate(Delegate)
    @CasePathable
    enum Delegate: Equatable {
      case saved(String)            // "이 이름으로 저장해 줘"
    }
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case let .draftChanged(text):
        state.draft = text
        return .none
      case .saveButtonTapped:
        let name = state.draft.trimmingCharacters(in: .whitespaces)
        guard !name.isEmpty else { return .none }   // 검증은 자식 몫
        return .send(.delegate(.saved(name)))
      case .delegate:
        return .none                  // 자식은 자기 delegate에 반응하지 않는다
      }
    }
  }
}
✅ 부모 — Scope를 먼저, 그다음 delegate만 듣는다
@Reducer
struct ProfileSettings {
  @ObservableState
  struct State: Equatable {
    var name = "Guest"
    var edit = ProfileEdit.State()
  }
  enum Action {
    case edit(ProfileEdit.Action)
  }
  var body: some ReducerOf<Self> {
    Scope(\.edit, action: \.edit) { ProfileEdit() }
    Reduce { state, action in
      switch action {
      case let .edit(.delegate(.saved(name))):
        state.name = name
        state.edit.draft = ""
        return .none
      case .edit:
        return .none                  // 나머지는 자식 내부 사정
      }
    }
  }
}

같은 일을 @Observable로 하면 부모가 자식에 콜백 클로저를 꽂는다. 동작은 같지만 순환 참조를 막는 [weak self]가 필요하고, 호출 기록이 어디에도 남지 않는다.

SwiftUI(@Observable) — 콜백 클로저로 위에 알린다
@Observable
final class ProfileEditModel {
  var draft = ""
  var onSaved: (String) -> Void = { _ in }   // 부모가 꽂아 준다
  func saveButtonTapped() {
    let name = draft.trimmingCharacters(in: .whitespaces)
    guard !name.isEmpty else { return }
    onSaved(name)
  }
}

@Observable
final class ProfileSettingsModel {
  var name = "Guest"
  let edit = ProfileEditModel()
  init() {
    edit.onSaved = { [weak self] name in      // 빠뜨리면 순환 참조
      self?.name = name
      self?.edit.draft = ""
    }
  }
}
엿보기 — 부모가 짐작 부모 Reduce case .edit(.saveButtonTapped) ProfileEdit (자식) 검증 로직은 여기 있는데… 내부 액션이 새어 나감 delegate — 자식이 요청 부모 Reduce case .edit(.delegate(.saved(n))) ProfileEdit (자식) .send(.delegate(.saved(n))) 검증 통과 후에만
delegate enum이 부모·자식 사이의 계약서가 된다. 자식은 버튼 이름·검증 규칙을 마음대로 바꿔도 되고, 부모는 계약서에 적힌 케이스만 처리한다.
🔑 규칙 세 줄

① 자식은 case .delegate: return .none — 자기 delegate에 로직을 넣지 않는다. ② 부모는 .child(.delegate(…))만 매칭하고 나머지 자식 액션은 case .child: return .none. ③ 닫기만 하는 경우는 delegate 대신 @Dependency(\.dismiss)가 있다 — Day 10에서 다룬다.

4. 옵셔널 자식 — ifLet과 if let store

자식이 항상 있지 않을 때가 있다. 다운로드 패널, 편집 모드, 로그인 후에만 보이는 영역이다. State를 Child.State?로 두고, Scope 대신 부모 리듀서 뒤에 .ifLet을 붙인다. Scope는 옵셔널이 아닌 key path만 받는다.

TCA — nil이면 자식 기능이 없다
@Reducer
struct Downloader {
  @ObservableState
  struct State: Equatable {
    var progress = 0.0
  }
  enum Action {
    case startButtonTapped
    case progressUpdated(Double)
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .startButtonTapped:
        return .run { send in
          for step in 1...10 {
            try await Task.sleep(for: .milliseconds(300))
            await send(.progressUpdated(Double(step) / 10))
          }
        }
      case let .progressUpdated(value):
        state.progress = value
        return .none
      }
    }
  }
}

@Reducer
struct DownloadScreen {
  @ObservableState
  struct State: Equatable {
    var download: Downloader.State?
  }
  enum Action {
    case download(Downloader.Action)
    case toggleButtonTapped
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .toggleButtonTapped:
        state.download = state.download == nil ? Downloader.State() : nil
        return .none
      case .download:
        return .none
      }
    }
    .ifLet(\.download, action: \.download) {
      Downloader()
    }
  }
}
TCA 뷰 — 옵셔널 스토어를 if let으로 푼다
struct DownloadScreenView: View {
  let store: StoreOf<DownloadScreen>
  var body: some View {
    Form {
      Button("다운로드 패널 켜기/끄기") { store.send(.toggleButtonTapped) }
      if let childStore = store.scope(\.download, action: \.download) {
        ProgressView(value: childStore.progress)
        Button("시작") { childStore.send(.startButtonTapped) }
      } else {
        Text("패널 없음")
      }
    }
  }
}

ifLet은 단순한 "nil이면 건너뛰기"가 아니다. 소스 문서가 세 가지를 약속한다(TCA 소스 · ifLet).

nil 자식 리듀서 안 돎 Downloader.State() 액션마다 자식 먼저 → 부모 .run 이펙트 진행 중 다시 nil 진행 중 이펙트를 ifLet이 자동 취소 toggle toggle @Observable이라면 Task를 직접 들고 있다가 cancel()을 불러야 하는 자리
자식 상태의 수명이 곧 자식 이펙트의 수명이다. 부모는 state.download = nil 한 줄만 쓰면 된다.
SwiftUI(@Observable) — 취소를 사람이 챙긴다
@MainActor @Observable
final class DownloaderModel {
  var progress = 0.0
  private var task: Task<Void, Never>?
  func startButtonTapped() {
    task = Task { [weak self] in
      for step in 1...10 {
        try? await Task.sleep(for: .milliseconds(300))
        guard !Task.isCancelled else { return }
        self?.progress = Double(step) / 10
      }
    }
  }
  func cancel() { task?.cancel() }
}

@MainActor @Observable
final class DownloadScreenModel {
  var download: DownloaderModel?
  func toggleButtonTapped() {
    if let download {
      download.cancel()               // 빠뜨리면 화면 밖에서 계속 돈다
      self.download = nil
    } else {
      download = DownloaderModel()
    }
  }
}
❌ .ifLet을 빠뜨렸다 — 컴파일은 되지만 "시작"을 눌러도 아무 일도 없다
var body: some ReducerOf<Self> {
  Reduce { state, action in
    // ...
    return .none
  }
  // .ifLet(\.download, action: \.download) { Downloader() }  ← 없음
  // → .download(.startButtonTapped)는 부모의 `case .download: return .none`에서 끝난다
}

화면 전환(시트·팝오버)용 옵셔널 자식은 @Presents와 .ifLet(\.$destination, …)를 쓴다. 같은 이름의 다른 오버로드이며 Day 10에서 다룬다.

5. CombineReducers와 로직 공유 — 액션 대신 메서드

CombineReducers: 묶음에 modifier 한 번

body는 이미 여러 리듀서를 차례로 합친다. CombineReducers가 필요한 때는 여러 리듀서 묶음에 modifier를 한 번에 걸고 싶을 때다. 구현은 빌더 결과를 저장했다가 그대로 위임하는 것뿐이다(TCA 소스 · CombineReducers).

TCA — 두 Scope에만 _printChanges를 건다
@Reducer
struct Dashboard {
  @ObservableState
  struct State: Equatable {
    var left = Counter.State()
    var right = Counter.State()
  }
  enum Action {
    case left(Counter.Action)
    case right(Counter.Action)
  }
  var body: some ReducerOf<Self> {
    CombineReducers {
      Scope(\.left, action: \.left) { Counter() }
      Scope(\.right, action: \.right) { Counter() }
    }
    ._printChanges()                  // 묶음 전체에 한 번
  }
}

로직 공유: 공유 액션을 보내지 말 것

여러 액션이 같은 계산을 해야 할 때 return .send(.recalculate)처럼 공유 액션을 만들고 싶어진다. 문서는 이를 권하지 않는다. 액션을 보내는 일은 메서드 호출보다 무겁고(여러 층을 지나며 가로채질 수 있다), 공유 로직이 항상 원래 로직 뒤에만 올 수 있고, 테스트에 receive(\.recalculate) 같은 내부 사정이 끼어든다.

📝 TCA 문서 원문

"It is far better to share logic via simple methods, for example on your Reducer conformance." — TCA 문서 · Performance · Sharing logic with actions

풀이: 리듀서 타입에 inout State를 받고 Effect<Action>을 돌려주는 메서드를 두고, 각 case에서 불러라.

✅ 공유 로직은 리듀서의 메서드로 — 액션 하나에 전부 끝난다
@Reducer
struct Checkout {
  @ObservableState
  struct State: Equatable {
    var quantity = 1
    var usesCoupon = false
    var total = 10_000
  }
  enum Action {
    case quantityChanged(Int)
    case couponToggled(Bool)
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case let .quantityChanged(value):
        state.quantity = value
        return self.recalculate(&state)
      case let .couponToggled(isOn):
        state.usesCoupon = isOn
        return self.recalculate(&state)
      }
    }
  }
  private func recalculate(_ state: inout State) -> Effect<Action> {
    state.total = state.quantity * 10_000 - (state.usesCoupon ? 2_000 : 0)
    return .none                        // 필요하면 .run도 돌려줄 수 있다
  }
}

@Observable 모델에서 private 메서드로 뽑던 것과 모양이 같다. 차이는 이 메서드가 상태를 직접 바꾸지 않고 inout으로 받은 복사본을 고친다는 점뿐이다. 부모가 자식 로직을 불러야 할 때는 .send(.child(.refresh))가 불가피하지만, 성능이 문제면 양쪽에서 부를 수 있는 헬퍼로 뽑으라고 문서가 덧붙인다.

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
Scope와 store.scope는 각각 무엇을 자르나? 하나만 쓰면 어떻게 되나?
Scope는 리듀서 층에서 부모 State·Action의 자식 몫을 잘라 자식 로직을 돌린다. store.scope는 뷰 층에서 자식 뷰에 줄 스토어를 자른다. store.scope만 쓰고 Scope를 빼면, 자식 뷰의 액션이 부모 스토어까지는 가지만 자식 리듀서가 없어 아무 상태도 바뀌지 않는다.
쉽게 말하면 Scope는 "이 방 일은 이 직원이 처리"라는 배치표, store.scope는 그 방 손님에게 주는 전용 호출벨이다. 호출벨만 있고 직원이 없으면 아무도 오지 않는다.
delegate 액션을 쓰는 이유는? 자식이 case .delegate:에서 .none만 돌려주는 이유는?
부모가 자식 내부 액션(버튼 탭)을 보고 의도를 짐작하면, 자식의 검증·흐름이 바뀔 때 부모가 조용히 틀린다. delegate enum은 "부모에게 요청할 일" 목록을 명시한 계약이다. 자식이 delegate에 로직을 넣으면 요청과 처리가 섞여 계약이 흐려지므로, 처리는 부모만 한다.
쉽게 말하면 엄마가 내 방을 엿보고 "숙제 끝났구나" 짐작하는 대신, 내가 "숙제 다 했어요, 확인해 주세요"라고 쪽지를 보내는 것이다.
🔮 예측용 코드 — OrderProbe
@Reducer
struct OrderProbe {
  @ObservableState
  struct State: Equatable {
    var counter = Counter.State()
    var seen: [Int] = []
  }
  enum Action {
    case counter(Counter.Action)
  }
  var body: some ReducerOf<Self> {
    Reduce { state, _ in
      state.seen.append(state.counter.count)   // 부모가 본 count 기록
      return .none
    }
    Scope(\.counter, action: \.counter) { Counter() }
  }
}
🔮 코드 예측: 위 OrderProbe에 .counter(.incrementButtonTapped)를 두 번 보내면 seen은? 두 리듀서의 순서를 바꾸면?
부모 Reduce가 위에 있으므로 부모가 먼저 돈다. 1회차: 부모가 count 0을 기록 → Counter가 1로. 2회차: 1을 기록 → 2로. 그래서 seen == [0, 1]. Scope를 위로 올리면 자식이 먼저 올린 값을 보므로 [1, 2]다. TestStore로 쓰면 첫 send는 await store.send(.counter(.incrementButtonTapped)) { $0.counter.count = 1; $0.seen = [0] }로 통과한다. ReducerBuilder가 적힌 순서대로 _reduce를 부르기 때문이다(소스 기준).
쉽게 말하면 줄 선 순서대로 들어간다. 부모가 앞에 서 있으면 자식이 숫자를 바꾸기 전의 값을 본다.
🔁 Day 4 복습: 자식 뷰에서 TextField("이름", text: $store.draft)를 쓰려면 자식 기능에 무엇이 있어야 하나?
자식 Action이 BindableAction을 채택하고 case binding(BindingAction<State>)를 가지며, body에 BindingReducer()가 있어야 한다. 뷰는 @Bindable var store: StoreOf<Child>로 받는다. 오늘 예제처럼 draftChanged(String) 액션을 유지하고 싶다면 $store.draft.sending(\.draftChanged)를 쓴다.
쉽게 말하면 양방향 리모컨을 쓰려면 기계(리듀서)에 수신기(BindingReducer)를 달고, 손(뷰)에 @Bindable 리모컨을 쥐여 줘야 한다.

🛠 미니 과제 (선택 · 10분)

상황: 장바구니 화면 Cart에 수량 선택 패널 QuantityPicker가 떴다 사라진다. Xcode 프로젝트에 TCA 패키지가 추가되어 있다고 가정한다.

  1. QuantityPicker: State value: Int, 액션 plusTapped, confirmTapped, delegate(.confirmed(Int)). 확인하면 delegate만 보낸다.
  2. Cart: quantity: Int, picker: QuantityPicker.State?. editTapped에서 현재 수량으로 패널을 연다. delegate를 받으면 수량을 반영하고 패널을 닫는다(ifLet 사용).
  3. 확인: TestStore로 "열기 → + 두 번 → 확인" 흐름이 통과해야 한다.
정답 코드 보기
정답 — Cart + QuantityPicker + TestStore
import Testing

@Reducer
struct QuantityPicker {
  @ObservableState
  struct State: Equatable {
    var value: Int
  }
  enum Action {
    case plusTapped
    case confirmTapped
    case delegate(Delegate)
    @CasePathable
    enum Delegate: Equatable {
      case confirmed(Int)
    }
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .plusTapped:
        state.value += 1
        return .none
      case .confirmTapped:
        return .send(.delegate(.confirmed(state.value)))
      case .delegate:
        return .none
      }
    }
  }
}

@Reducer
struct Cart {
  @ObservableState
  struct State: Equatable {
    var quantity = 1
    var picker: QuantityPicker.State?
  }
  enum Action {
    case editTapped
    case picker(QuantityPicker.Action)
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .editTapped:
        state.picker = QuantityPicker.State(value: state.quantity)
        return .none
      case let .picker(.delegate(.confirmed(value))):
        state.quantity = value
        state.picker = nil
        return .none
      case .picker:
        return .none
      }
    }
    .ifLet(\.picker, action: \.picker) { QuantityPicker() }
  }
}

@MainActor
@Test func cartFlow() async {
  let store = TestStore(initialState: Cart.State()) { Cart() }
  await store.send(.editTapped) { $0.picker = QuantityPicker.State(value: 1) }
  await store.send(.picker(.plusTapped)) { $0.picker?.value = 2 }
  await store.send(.picker(.plusTapped)) { $0.picker?.value = 3 }
  await store.send(.picker(.confirmTapped))
  await store.receive(\.picker.delegate.confirmed) {
    $0.quantity = 3
    $0.picker = nil
  }
}

출처 · 참고자료