iOS Dev between Combine and Delegate/Closure

When to use Combine versus the delegate and closure approaches is a great point to clarify because many developers struggle to choose the right tool for different scenarios, especially as Apple continues to expand its reactive frameworks while traditional patterns remain widely used. This post pretends to highlight the trade-offs—such as Combine’s power in managing complex, asynchronous data streams and chaining operators, versus the simplicity and low-overhead of closures for one-off callbacks or delegates for structured, reusable communication.

The dilemma

Combine, delegates, and closures all solve the same core problem: communicating results or events between components asynchronously.

Here’s what they share in common:

  • Asynchronous communication → all three let one part of your app notify another when “something happened” without blocking.

  • Decoupling → the caller doesn’t need to know how the callee works, only what result or event it sends back.

  • Custom API design → you can choose the style (reactive stream, callback, or protocol) that best fits your use case, but under the hood they’re all just passing messages across boundaries.

  • Memory management concerns → all require care with retain cycles ([weak self] in closures, weak delegates, cancellables in Combine).

  • Interoperability → you can wrap one into another (e.g., turn a delegate or closure into a Publisher, or call a closure inside a delegate method).

In short: they’re different abstractions over the same idea of event handling and result delivery, just with different levels of structure and power.

The rule of thumb

Use Combine when…

  • You’re dealing with streams of values over time (state updates, network retries, text search with debounce, multi-source merges).

  • You need operator chains (map/filter/flatMap/throttle/retry/catch) and cancellable pipelines you can pause or tear down with a single AnyCancellable.

  • You’re deep in SwiftUI (@Published, ObservableObject, view models) or want fan-out to multiple subscribers.

  • Testing with schedulers and time (virtual clocks) matters.

Use delegates/closures when…

  • It’s a one-shot or simple callback (“user picked photo”, “request finished”, “button tapped”).

  • You want minimal overhead & maximal clarity in local APIs; fewer allocations and less indirection.

  • The interaction is structured and two-way (classic UIKit delegate lifecycles) or you’re writing a library that shouldn’t force a reactive dependency.

Examples

Example (type-ahead search with debouncing & cancellation):

import Combine
import UIKit

final class SearchViewModel {
    @Published var query: String = ""
    @Published private(set) var results: [Repo] = []

    private var bag = Set<AnyCancellable>()
    private let api = GitHubAPI()

    init() {
        $query
            .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
            .removeDuplicates()
            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
            .filter { !$0.isEmpty }
            .flatMap { q in
                self.api.searchRepos(query: q)              // -> AnyPublisher<[Repo], Error>
                    .replaceError(with: [])                 // surface errors as empty for UX
            }
            .receive(on: RunLoop.main)
            .assign(to: &$results)
    }
}

struct Repo: Decodable { let name: String }

final class GitHubAPI {
    func searchRepos(query: String) -> AnyPublisher<[Repo], Error> {
        let url = URL(string: "https://api.github.com/search/repositories?q=\(query)")!
        return URLSession.shared.dataTaskPublisher(for: url)
            .map(\.data)
            .decode(type: SearchResponse.self, decoder: JSONDecoder())
            .map(\.items)
            .eraseToAnyPublisher()
    }
}

struct SearchResponse: Decodable { let items: [Repo] }

Example (delegate for a child picker reporting a single selection):

protocol ColorPickerViewControllerDelegate: AnyObject {
    func colorPicker(_ picker: ColorPickerViewController, didSelect color: UIColor)
}

final class ColorPickerViewController: UIViewController {
    weak var delegate: ColorPickerViewControllerDelegate?

    // call once when a color is chosen
    private func didChoose(_ color: UIColor) {
        delegate?.colorPicker(self, didSelect: color)
        dismiss(animated: true)
    }
}

// Parent sets itself as delegate
final class SettingsViewController: UIViewController, ColorPickerViewControllerDelegate {
    func presentPicker() {
        let picker = ColorPickerViewController()
        picker.delegate = self
        present(picker, animated: true)
    }

    func colorPicker(_ picker: ColorPickerViewController, didSelect color: UIColor) {
        view.backgroundColor = color
    }
}

Closure variant (simple one-shot completion):

final class ImageLoader {
    func load(_ url: URL, completion: @escaping (Result<UIImage, Error>) -> Void) {
        URLSession.shared.dataTask(with: url) { data, _, error in
            if let e = error { completion(.failure(e)); return }
            guard let data = data, let image = UIImage(data: data) else {
                completion(.failure(NSError(domain: "decode", code: -1)))
                return
            }
            completion(.success(image))
        }.resume()
    }
}

Quick decision checklist

  • Do I expect multiple values over time that I want to transform/combine? → Combine

  • Is it a single, directed callback (child → parent or one network response)? → Delegate/Closure

  • Do I need easy cancellation, debouncing, retries, or operator chains? → Combine

  • Is simplicity, minimal dependencies, or pre–iOS 13 support important? → Delegate/Closure

💡Tip: don’t force reactive patterns everywhere—use Combine where its composability shines, and keep the rest lightweight.

Bridge between them

Combine and delegates/closures can absolutely be mixed in the same project, and in fact that’s often the most pragmatic approach. They aren’t mutually exclusive, they just solve different layers of the problem:

  • Frameworks & SDKs: Many Apple and third-party APIs still expose delegates or completion handlers (e.g. CLLocationManager, URLSession).

  • Your app architecture: You might want to stay “Combine-first” in your view models, but still need to talk to APIs that rely on delegate or closure callbacks.

Example: turning a delegate into a Combine publisher:
import Combine
import CoreLocation

final class LocationManagerDelegateProxy: NSObject, CLLocationManagerDelegate {
    let subject = PassthroughSubject<CLLocation, Never>()

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        if let loc = locations.last {
            subject.send(loc)
        }
    }
}

// Usage
let locationManager = CLLocationManager()
let proxy = LocationManagerDelegateProxy()
locationManager.delegate = proxy

let cancellable = proxy.subject
    .sink { location in
        print("Got location:", location)
    }

Example: wrapping a closure in a publisher

func loadImagePublisher(from url: URL) -> AnyPublisher<UIImage, Error> {
    Future { promise in
        URLSession.shared.dataTask(with: url) { data, _, error in
            if let e = error {
                promise(.failure(e))
            } else if let data, let image = UIImage(data: data) {
                promise(.success(image))
            } else {
                promise(.failure(NSError(domain: "decode", code: -1)))
            }
        }.resume()
    }
    .eraseToAnyPublisher()
}

Conclusions

I hope this helped clarify when it makes sense to use Combine and when a delegate or closure is the better fit. Most importantly, remember that these approaches are not mutually exclusive—you don’t need to force reactive patterns into every situation. Instead, use Combine where its strengths truly shine, and rely on delegates or closures when simplicity is all you need.

References

  • Combine

    Apple Developer Documentation

Copyright © 2024-2025 JaviOS. All rights reserved