home / skills / kaakati / rails-enterprise-dev / combine-reactive

This skill guides you to choose between Combine and async/await, design robust operator chains, and prevent memory leaks in reactive streams.

npx playbooks add skill kaakati/rails-enterprise-dev --skill combine-reactive

Review the files below or copy the command above to add this skill to your agents.

Files (2)
SKILL.md
11.4 KB
---
name: combine-reactive
description: "Expert Combine decisions for iOS/tvOS: when Combine vs async/await, Subject selection trade-offs, operator chain design, and memory management patterns. Use when implementing reactive streams, choosing between concurrency models, or debugging Combine memory leaks. Trigger keywords: Combine, Publisher, Subscriber, Subject, PassthroughSubject, CurrentValueSubject, async/await, AnyCancellable, sink, operators, reactive"
version: "3.0.0"
---

# Combine Reactive — Expert Decisions

Expert decision frameworks for Combine choices. Claude knows Combine syntax — this skill provides judgment calls for when Combine adds value vs async/await and how to avoid common pitfalls.

---

## Decision Trees

### Combine vs Async/Await

```
What's your data flow pattern?
├─ Single async operation (fetch once)
│  └─ async/await
│     Simpler, built into language
│
├─ Stream of values over time
│  └─ Is it UI-driven (user events)?
│     ├─ YES → Combine (debounce, throttle shine here)
│     └─ NO → AsyncSequence may suffice
│
├─ Combining multiple data sources
│  └─ How many sources?
│     ├─ 2-3 → Combine's CombineLatest, Zip
│     └─ Many → Consider structured concurrency (TaskGroup)
│
└─ Existing codebase uses Combine?
   └─ Maintain consistency unless migrating
```

**The trap**: Using Combine for simple one-shot async. `async/await` is cleaner and doesn't need cancellable management.

### Subject Type Selection

```
Do you need to access current value?
├─ YES → CurrentValueSubject
│  Can read .value synchronously
│  New subscribers get current value immediately
│
└─ NO → Is it event-based (discrete occurrences)?
   ├─ YES → PassthroughSubject
   │  No stored value, subscribers only get new emissions
   │
   └─ NO (need replay of past values) →
      Consider external state management
      Combine has no built-in ReplaySubject
```

### Operator Chain Design

```
What transformation is needed?
├─ Value transformation
│  └─ map (simple), compactMap (filter nils), flatMap (nested publishers)
│
├─ Filtering
│  └─ filter, removeDuplicates, first, dropFirst
│
├─ Timing
│  └─ User input → debounce (wait for pause)
│     Scrolling → throttle (rate limit)
│
├─ Error handling
│  └─ Can provide fallback? → catch, replaceError
│     Need retry? → retry(n)
│
└─ Combining streams
   └─ Need all values paired? → zip
      Need latest from each? → combineLatest
      Merge into one stream? → merge
```

---

## NEVER Do

### Subscription Management

**NEVER** forget to store subscriptions:
```swift
// ❌ Subscription cancelled immediately
func loadData() {
    publisher.sink { value in
        self.data = value  // Never called!
    }
    // sink returns AnyCancellable that's immediately deallocated
}

// ✅ Store in Set<AnyCancellable>
private var cancellables = Set<AnyCancellable>()

func loadData() {
    publisher.sink { value in
        self.data = value
    }
    .store(in: &cancellables)
}
```

**NEVER** capture self strongly in sink closures:
```swift
// ❌ Retain cycle — ViewModel never deallocates
publisher
    .sink { value in
        self.updateUI(value)  // Strong capture!
    }
    .store(in: &cancellables)

// ✅ Use [weak self]
publisher
    .sink { [weak self] value in
        self?.updateUI(value)
    }
    .store(in: &cancellables)
```

**NEVER** use assign(to:on:) with classes:
```swift
// ❌ Strong reference to self — retain cycle
publisher
    .assign(to: \.text, on: self)  // Retains self!
    .store(in: &cancellables)

// ✅ Use sink with weak self
publisher
    .sink { [weak self] value in
        self?.text = value
    }
    .store(in: &cancellables)

// Or use assign(to:) with @Published (no retain cycle)
publisher
    .assign(to: &$text)  // Safe — doesn't return cancellable
```

### Subject Misuse

**NEVER** expose subjects directly:
```swift
// ❌ External code can send values
class ViewModel {
    let users = PassthroughSubject<[User], Never>()  // Public subject!
}

// External code:
viewModel.users.send([])  // Breaks encapsulation

// ✅ Expose as Publisher
class ViewModel {
    private let usersSubject = PassthroughSubject<[User], Never>()

    var users: AnyPublisher<[User], Never> {
        usersSubject.eraseToAnyPublisher()
    }
}
```

**NEVER** send on subject from multiple threads without care:
```swift
// ❌ Race condition — undefined behavior
DispatchQueue.global().async {
    subject.send(value1)
}
DispatchQueue.global().async {
    subject.send(value2)
}

// ✅ Serialize access
let subject = PassthroughSubject<Value, Never>()
let serialQueue = DispatchQueue(label: "subject.serial")

serialQueue.async {
    subject.send(value)
}

// Or use .receive(on:) to ensure delivery on specific scheduler
```

### Operator Mistakes

**NEVER** use flatMap when you mean map:
```swift
// ❌ Confusing — flatMap for non-publisher transformation
publisher
    .flatMap { user -> AnyPublisher<String, Never> in
        Just(user.name).eraseToAnyPublisher()  // Overkill!
    }

// ✅ Use map for simple transformation
publisher
    .map { user in user.name }
```

**NEVER** forget to handle errors in sink:
```swift
// ❌ Compiler allows this but errors are ignored
publisher  // Has Error type
    .sink { value in
        // Only handles values, error terminates silently
    }

// ✅ Handle both completion and value
publisher
    .sink(
        receiveCompletion: { completion in
            if case .failure(let error) = completion {
                handleError(error)
            }
        },
        receiveValue: { value in
            handleValue(value)
        }
    )
```

**NEVER** debounce without scheduler on main:
```swift
// ❌ Debouncing on background scheduler, updating UI
searchText
    .debounce(for: .milliseconds(300), scheduler: DispatchQueue.global())
    .sink { [weak self] text in
        self?.results = search(text)  // UI update on background thread!
    }

// ✅ Receive on main for UI updates
searchText
    .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
    .sink { [weak self] text in
        self?.results = search(text)
    }

// Or explicitly receive on main
searchText
    .debounce(for: .milliseconds(300), scheduler: DispatchQueue.global())
    .receive(on: DispatchQueue.main)
    .sink { ... }
```

---

## Essential Patterns

### ViewModel with Combine

```swift
@MainActor
final class SearchViewModel: ObservableObject {
    @Published var searchText = ""
    @Published private(set) var results: [SearchResult] = []
    @Published private(set) var isLoading = false
    @Published private(set) var error: Error?

    private let searchService: SearchServiceProtocol
    private var cancellables = Set<AnyCancellable>()

    init(searchService: SearchServiceProtocol) {
        self.searchService = searchService
        setupSearch()
    }

    private func setupSearch() {
        $searchText
            .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
            .removeDuplicates()
            .filter { !$0.isEmpty }
            .handleEvents(receiveOutput: { [weak self] _ in
                self?.isLoading = true
                self?.error = nil
            })
            .flatMap { [weak self] query -> AnyPublisher<[SearchResult], Never> in
                guard let self = self else {
                    return Just([]).eraseToAnyPublisher()
                }
                return self.searchService.search(query: query)
                    .catch { [weak self] error -> Just<[SearchResult]> in
                        self?.error = error
                        return Just([])
                    }
                    .eraseToAnyPublisher()
            }
            .receive(on: DispatchQueue.main)
            .sink { [weak self] results in
                self?.results = results
                self?.isLoading = false
            }
            .store(in: &cancellables)
    }
}
```

### Combine to Async Bridge

```swift
extension Publisher where Failure == Error {
    func async() async throws -> Output {
        try await withCheckedThrowingContinuation { continuation in
            var cancellable: AnyCancellable?
            var didResume = false

            cancellable = first()
                .sink(
                    receiveCompletion: { completion in
                        guard !didResume else { return }
                        didResume = true

                        switch completion {
                        case .finished:
                            break  // Value handled in receiveValue
                        case .failure(let error):
                            continuation.resume(throwing: error)
                        }
                        cancellable?.cancel()
                    },
                    receiveValue: { value in
                        guard !didResume else { return }
                        didResume = true
                        continuation.resume(returning: value)
                    }
                )
        }
    }
}

// Usage
Task {
    let user = try await fetchUserPublisher.async()
}
```

### Event Bus Pattern

```swift
final class EventBus {
    static let shared = EventBus()

    // Private subjects
    private let userLoggedInSubject = PassthroughSubject<User, Never>()
    private let userLoggedOutSubject = PassthroughSubject<Void, Never>()
    private let cartUpdatedSubject = PassthroughSubject<Cart, Never>()

    // Public publishers (read-only)
    var userLoggedIn: AnyPublisher<User, Never> {
        userLoggedInSubject.eraseToAnyPublisher()
    }

    var userLoggedOut: AnyPublisher<Void, Never> {
        userLoggedOutSubject.eraseToAnyPublisher()
    }

    var cartUpdated: AnyPublisher<Cart, Never> {
        cartUpdatedSubject.eraseToAnyPublisher()
    }

    // Send methods
    func send(userLoggedIn user: User) {
        userLoggedInSubject.send(user)
    }

    func sendUserLoggedOut() {
        userLoggedOutSubject.send()
    }

    func send(cartUpdated cart: Cart) {
        cartUpdatedSubject.send(cart)
    }
}
```

---

## Quick Reference

### Combine vs Async/Await Decision

| Scenario | Prefer |
|----------|--------|
| Single async call | async/await |
| Stream of values | Combine |
| User input debouncing | Combine |
| Combining multiple API calls | Either (async let or CombineLatest) |
| Existing Combine codebase | Combine for consistency |
| New project, simple needs | async/await |

### Subject Comparison

| Subject | Stores Value | New Subscribers Get |
|---------|--------------|---------------------|
| PassthroughSubject | No | Only new values |
| CurrentValueSubject | Yes | Current + new values |

### Common Operators

| Category | Operators |
|----------|-----------|
| Transform | map, flatMap, compactMap, scan |
| Filter | filter, removeDuplicates, first, dropFirst |
| Combine | combineLatest, zip, merge |
| Timing | debounce, throttle, delay |
| Error | catch, retry, replaceError |

### Red Flags

| Smell | Problem | Fix |
|-------|---------|-----|
| Subscription not stored | Immediate cancellation | .store(in: &cancellables) |
| Strong self in sink | Retain cycle | [weak self] |
| assign(to:on:self) | Retain cycle | Use sink or assign(to:&$property) |
| Public Subject | Encapsulation broken | Expose as AnyPublisher |
| flatMap for simple transform | Overkill | Use map |
| Debounce on background, sink updates UI | Threading bug | receive(on: .main) |
| Empty receiveCompletion | Errors ignored | Handle .failure case |

Overview

This skill codifies expert decision-making for Combine in iOS/tvOS projects. It helps you choose between Combine and async/await, pick the right Subject, design operator chains, and avoid common memory and threading pitfalls. Use it when implementing reactive streams, bridging to async APIs, or troubleshooting leaks and race conditions.

How this skill works

The skill inspects your data-flow pattern and maps it to a recommendation: single one-shot tasks favor async/await while continuous or UI-driven streams favor Combine. It evaluates Subject needs (CurrentValueSubject vs PassthroughSubject), suggests operator selections (map, flatMap, debounce, combineLatest, etc.), and enforces subscription and threading best practices to prevent retain cycles and race conditions. It also provides bridging patterns to convert Publishers to async functions and patterns for exposing publishers safely.

When to use it

  • Deciding between Combine and async/await for a feature
  • Designing a UI-driven stream (search, input throttling, live filtering)
  • Combining multiple publishers from different sources
  • Debugging memory leaks or retain cycles involving AnyCancellable and sink
  • Implementing an event bus or pub/sub boundaries
  • Bridging Combine publishers into async/await codepaths

Best practices

  • Prefer async/await for single, one-shot async operations to avoid cancellable management
  • Use CurrentValueSubject only when synchronous access to current value is required; use PassthroughSubject for event streams
  • Never expose subjects directly; expose AnyPublisher to preserve encapsulation
  • Always store subscriptions in a Set<AnyCancellable> and use [weak self] in closures to avoid retain cycles
  • Schedule timing operators on the appropriate scheduler and receive on Main for UI updates
  • Use map for simple transforms and flatMap only when working with nested publishers

Example use cases

  • Debounced search box: $searchText.debounce(...).flatMap { searchService.search(...) }
  • EventBus for cross-module communication exposing AnyPublisher channels
  • Combining user profile and settings streams with combineLatest to update UI state
  • Converting an existing Publisher-returning API into an async function with withCheckedThrowingContinuation
  • Diagnosing a ViewModel that never deallocates by checking stored AnyCancellable and assign(to:on:) usage

FAQ

When should I migrate existing Combine code to async/await?

Migrate when flows are simple one-shot calls and the migration cost is low; keep Combine where you rely on continuous streams, advanced operators, or existing architecture consistency.

How do I avoid race conditions when sending on a Subject?

Serialize sends through a dedicated serial DispatchQueue or ensure delivery on a single scheduler with receive(on:). Never send concurrently from multiple threads.