swift-combine

SKILL.md

Swift Combine Skill

Reactive programming with Apple's Combine framework for handling asynchronous events and data streams.

Prerequisites

  • iOS 13+ / macOS 10.15+
  • Understanding of closures and generics
  • Familiarity with async programming concepts

Parameters

parameters:
  use_async_await:
    type: boolean
    default: true
    description: Prefer async/await where possible (iOS 15+)
  scheduler:
    type: string
    enum: [main, background, immediate]
    default: main
  error_handling:
    type: string
    enum: [catch, retry, replace]
    default: catch

Topics Covered

Core Concepts

Concept Description
Publisher Emits values over time
Subscriber Receives values from publisher
Operator Transforms/filters values
Subject Publisher + manual value injection
Cancellable Subscription lifecycle

Common Publishers

Publisher Purpose
Just Single value, then complete
Future Single async result
PassthroughSubject Manual value broadcast
CurrentValueSubject Manual + current value
@Published Property wrapper publisher

Key Operators

Category Operators
Transform map, flatMap, scan
Filter filter, removeDuplicates, compactMap
Combine merge, combineLatest, zip
Timing debounce, throttle, delay
Error catch, retry, mapError

Code Examples

Basic Publisher Chain

import Combine

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 var cancellables = Set<AnyCancellable>()
    private let searchService: SearchService

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

    private func setupSearch() {
        $searchText
            .debounce(for: .milliseconds(300), scheduler: RunLoop.main) // Wait for typing pause
            .removeDuplicates() // Don't search same query twice
            .filter { $0.count >= 2 } // Minimum characters
            .handleEvents(receiveOutput: { [weak self] _ in
                self?.isLoading = true
                self?.error = nil
            })
            .flatMap { [searchService] query -> AnyPublisher<[SearchResult], Never> in
                searchService.search(query: query)
                    .catch { error -> Just<[SearchResult]> in
                        // Handle error, return empty
                        return Just([])
                    }
                    .eraseToAnyPublisher()
            }
            .receive(on: DispatchQueue.main)
            .sink { [weak self] results in
                self?.isLoading = false
                self?.results = results
            }
            .store(in: &cancellables)
    }
}

Subjects for Event Broadcasting

final class EventBus {
    static let shared = EventBus()

    // PassthroughSubject - no current value
    let userActions = PassthroughSubject<UserAction, Never>()

    // CurrentValueSubject - maintains current value
    let authState = CurrentValueSubject<AuthState, Never>(.loggedOut)

    private init() {}

    func send(_ action: UserAction) {
        userActions.send(action)
    }

    func login(user: User) {
        authState.send(.loggedIn(user))
    }

    func logout() {
        authState.send(.loggedOut)
    }
}

enum UserAction {
    case tappedButton(String)
    case viewedScreen(String)
    case completedPurchase(orderId: String)
}

enum AuthState {
    case loggedOut
    case loggedIn(User)
}

// Subscription
class AnalyticsService {
    private var cancellables = Set<AnyCancellable>()

    init() {
        EventBus.shared.userActions
            .sink { [weak self] action in
                self?.track(action)
            }
            .store(in: &cancellables)
    }

    private func track(_ action: UserAction) {
        // Send to analytics
    }
}

Combining Multiple Publishers

final class CheckoutViewModel: ObservableObject {
    @Published var cartItems: [CartItem] = []
    @Published var shippingAddress: Address?
    @Published var paymentMethod: PaymentMethod?
    @Published var promoCode: String = ""

    @Published private(set) var canCheckout = false
    @Published private(set) var total: Decimal = 0

    private var cancellables = Set<AnyCancellable>()

    init() {
        setupValidation()
        setupTotalCalculation()
    }

    private func setupValidation() {
        Publishers.CombineLatest3($cartItems, $shippingAddress, $paymentMethod)
            .map { items, address, payment in
                !items.isEmpty && address != nil && payment != nil
            }
            .assign(to: &$canCheckout)
    }

    private func setupTotalCalculation() {
        Publishers.CombineLatest($cartItems, $promoCode)
            .map { items, promo -> Decimal in
                let subtotal = items.reduce(0) { $0 + $1.price * Decimal($1.quantity) }
                let discount = self.calculateDiscount(promo: promo, subtotal: subtotal)
                return subtotal - discount
            }
            .assign(to: &$total)
    }

    private func calculateDiscount(promo: String, subtotal: Decimal) -> Decimal {
        // Promo code logic
        return 0
    }
}

Error Handling & Retry

extension Publisher {
    func retryWithBackoff(
        maxRetries: Int = 3,
        initialDelay: TimeInterval = 1,
        maxDelay: TimeInterval = 30
    ) -> AnyPublisher<Output, Failure> {
        self.catch { error -> AnyPublisher<Output, Failure> in
            guard maxRetries > 0 else {
                return Fail(error: error).eraseToAnyPublisher()
            }

            let delay = min(initialDelay * pow(2, Double(3 - maxRetries)), maxDelay)

            return Just(())
                .delay(for: .seconds(delay), scheduler: DispatchQueue.global())
                .flatMap { _ in
                    self.retryWithBackoff(
                        maxRetries: maxRetries - 1,
                        initialDelay: initialDelay,
                        maxDelay: maxDelay
                    )
                }
                .eraseToAnyPublisher()
        }
        .eraseToAnyPublisher()
    }
}

// Usage
apiClient.fetchData()
    .retryWithBackoff(maxRetries: 3)
    .catch { error -> Just<Data> in
        Logger.network.error("Final failure: \(error)")
        return Just(Data())
    }
    .sink { data in
        // Process data
    }
    .store(in: &cancellables)

Bridging to async/await

extension Publisher {
    func firstValue() async throws -> Output {
        try await withCheckedThrowingContinuation { continuation in
            var cancellable: AnyCancellable?

            cancellable = self.first()
                .sink(
                    receiveCompletion: { completion in
                        switch completion {
                        case .finished:
                            break
                        case .failure(let error):
                            continuation.resume(throwing: error)
                        }
                        cancellable?.cancel()
                    },
                    receiveValue: { value in
                        continuation.resume(returning: value)
                    }
                )
        }
    }
}

// Usage
let result = try await somePublisher.firstValue()

Troubleshooting

Common Issues

Issue Cause Solution
Sink never called No strong reference Store in cancellables Set
UI not updating Wrong scheduler Use .receive(on: DispatchQueue.main)
Memory leak Strong reference in closure Use [weak self]
Duplicate events Missing removeDuplicates Add .removeDuplicates()
Publisher completes early Using first() or prefix() Check operator semantics

Debug Tips

// Print debug info for each event
publisher
    .print("Debug")
    .sink { ... }

// Handle events at each stage
publisher
    .handleEvents(
        receiveSubscription: { _ in print("Subscribed") },
        receiveOutput: { print("Output: \($0)") },
        receiveCompletion: { print("Completed: \($0)") },
        receiveCancel: { print("Cancelled") }
    )
    .sink { ... }

// Breakpoint on specific conditions
publisher
    .breakpoint(receiveOutput: { $0 > 100 })
    .sink { ... }

Validation Rules

validation:
  - rule: store_cancellables
    severity: error
    check: All subscriptions must be stored in cancellables
  - rule: weak_self_in_closures
    severity: warning
    check: Use [weak self] in sink closures
  - rule: receive_on_main
    severity: warning
    check: UI updates must receive on main queue

Usage

Skill("swift-combine")

Related Skills

  • swift-swiftui - @Published integration
  • swift-concurrency - async/await alternative
  • swift-networking - Network publishers
Weekly Installs
4
Installed on
claude-code3
windsurf2
trae2
opencode2
codex2
antigravity2