Irbisa · Cheatsheet

Combine

Publishers, Subscribers, operators, Subjects, bridging with async/await

Senior Developer · 24 questions

  1. 01

    What is Combine, and how do Publisher, Subscriber and operators fit together?

    Combine is Apple's reactive framework (iOS 13+): a Publisher emits values over time followed by one completion, operators transform that stream, and a Subscriber at the end requests and receives the values.

    import Combine let cancellable = [1, 2, 3, 4, 5].publisher   // Publisher<Int, Never>  .map { $0 * 2 }                              // operator  .filter { $0 > 4 }                           // operator  .sink(                                       // subscriber…
  2. 02

    What is the difference between PassthroughSubject and CurrentValueSubject?

    PassthroughSubject forwards each value only to whoever is subscribed at that moment and keeps nothing; CurrentValueSubject stores the latest value, exposes it as .value and hands it to every new subscriber immediately.

  3. 03

    What is the difference between debounce and throttle?

    debounce waits until the upstream has been quiet for the given interval and then emits the last value; throttle emits at most one value per interval while values keep arriving.

  4. 04

    What is AnyCancellable, and how do you manage the lifetime and memory of a subscription?

    AnyCancellable is the token a subscription returns: it cancels the subscription when you call cancel() or when it is deallocated, so a pipeline lives exactly as long as someone holds that token.

    private var bag = Set<AnyCancellable>()private var searchCancellable: AnyCancellable? // wrong: the token is released at once, the subscription is cancelledrequest.sink { _ in }…
  5. 05

    Why does this view model never print deinit, and how do assign(to:on:) and assign(to:) differ?

    final class ProfileViewModel {  @Published var title = ""  private var bag = Set<AnyCancellable>()   init(user: AnyPublisher<User, Never>) {    user      .map(\.name)      .assign(to: \.title, on: self)      .store(in: &bag)  }   deinit { print("deinit") }}

    assign(to:on:) keeps a strong reference to the object it writes to, and the returned cancellable is stored in that same object, so self → bag → subscription → self is a retain cycle that lasts as long as the upstream does not finish.

  6. 06

    Find the bug: after a few trips to a post and back, every feed update is rendered and logged several times.

    final class FeedViewController: UIViewController {  private let viewModel = FeedViewModel()  private var bag = Set<AnyCancellable>()   override func viewWillAppear(_ animated: Bool) {    super.viewWillAppear(animated)    viewModel.$posts      .receive(on: DispatchQueue.main)      .sink { [weak self] posts in        self?.render(posts)        Analytics.log("feed_shown", count: posts.count)      }      .store(in: &bag)  }}

    The subscription is created in viewWillAppear, which runs every time the screen comes back, and each new one is added to bag while the old ones stay alive.

  7. 07

    What does eraseToAnyPublisher() do, and why do APIs return AnyPublisher?

    eraseToAnyPublisher() wraps a publisher in AnyPublisher<Output, Failure> and hides the nested operator types, so an API exposes only what it emits and how it fails, not how the chain is built.

    // The concrete type would be a long nest of Publishers.* genericsfunc posts() -> AnyPublisher<[Post], Error> {  URLSession.shared.dataTaskPublisher(for: postsURL)    .map(\.data)    .decode(type: [Post].self, decoder: JSONDecoder())    .eraseToAnyPublisher()…
  8. 08

    What is the difference between merge, combineLatest and zip?

    merge interleaves values of the same type from several publishers; combineLatest emits a tuple of the latest value from each input whenever any input changes; zip pairs values by position and emits only when every input has a new one.

    let a = PassthroughSubject<Int, Never>()let b = PassthroughSubject<String, Never>()var bag = Set<AnyCancellable>() a.combineLatest(b).sink { print("latest", $0, $1) }.store(in: &bag)a.zip(b).sink { print("zip", $0, $1) }.store(in: &bag)…
  9. 09

    What is the difference between flatMap and switchToLatest, and which one does a search field need?

    flatMap keeps every inner publisher alive and merges their results, so a slow old request can overwrite a newer one; map followed by switchToLatest cancels the previous inner publisher when a new value arrives, which is what a search field needs.

    final class SearchViewModel: ObservableObject {  @Published var query = ""  @Published private(set) var results: [Match] = []   init(api: SearchAPI) {    $query…
  10. 10

    What does this code print, and how do you keep the stream alive after an error?

    let subject = PassthroughSubject<Int, Never>()let cancellable = subject  .tryMap { value -> Int in    if value == 2 { throw URLError(.badURL) }    return value * 10  }  .replaceError(with: -1)  .sink { print($0) } subject.send(1)subject.send(2)subject.send(3)

    It prints 10 and -1: the thrown error is a completion, so replaceError emits one last value and the stream ends, and 3 never arrives.

  11. 11

    What are hot and cold publishers, and which Combine publishers are which?

    A cold publisher starts its work separately for each subscriber at the moment of subscription; a hot one produces values on its own schedule whether or not anyone listens, so a late subscriber sees only what comes after it joins.

  12. 12

    Find the bug: after one "Saved" toast, every new screen the user opens shows the same toast again.

    @MainActor final class ToastCenter {  static let shared = ToastCenter()  let messages = CurrentValueSubject<String?, Never>(nil)  func show(_ text: String) { messages.send(text) }} class BaseViewController: UIViewController {  private var bag = Set<AnyCancellable>()  override func viewDidLoad() {    super.viewDidLoad()    ToastCenter.shared.messages      .compactMap { $0 }      .sink { [weak self] in self?.presentToast($0) }      .store(in: &bag)  }}

    CurrentValueSubject stores the last value and sends it to every new subscriber, so each screen that subscribes in viewDidLoad gets the old message at once and shows the toast again.

  13. 13

    What is Future in Combine, and why is it usually wrapped in Deferred?

    Future is a publisher that delivers exactly one value or one error through its promise callback; it runs its closure as soon as it is created and caches the result, so Deferred is needed to make it lazy and repeatable.

    // wrong: the request starts when the Future is createdfunc eagerUser(id: String) -> AnyPublisher<User, Error> {  Future { promise in    print("request fired")             // runs before anyone subscribes    api.loadUser(id: id) { result in promise(result) }  }…
  14. 14

    What is the difference between receive(on:) and subscribe(on:)?

    receive(on:) sets the scheduler on which values and the completion are delivered to everything below it; subscribe(on:) sets where the upstream receives the subscription, requests and cancellation, which is where the work starts, and does not control delivery (a synchronous upstream emits on that queue anyway).

    // wrong: the hop to main happens before the expensive workURLSession.shared.dataTaskPublisher(for: url)  .receive(on: DispatchQueue.main)  .map(\.data)  .decode(type: [Post].self, decoder: JSONDecoder())   // decodes on the main thread  .replaceError(with: [])…
  15. 15

    What does this code print, and why does the sink see the old value of items?

    final class Cart: ObservableObject {  @Published var items: [String] = []  private var bag = Set<AnyCancellable>()   init() {    $items      .sink { [weak self] newItems in        print(newItems.count, self?.items.count ?? -1)      }      .store(in: &bag)  }} let cart = Cart()cart.items = ["apple", "pear"]

    It prints 0 0 and then 2 0: $items emits the current value on subscription, and @Published publishes from willSet, so the sink runs before the property is written and self.items still holds the old array.

  16. 16

    Which Combine publishers do Foundation and UIKit give you, and how do you get one for a UIButton?

    Apple ships a short list (NotificationCenter, KVO, Timer, URLSession and your own @Published properties), and there is no built-in publisher for UIControl, so for a button you wrap UIAction in a subject yourself.

    extension UIControl {  func publisher(for event: UIControl.Event) -> AnyPublisher<Void, Never> {    let subject = PassthroughSubject<Void, Never>()    let action = UIAction { _ in subject.send(()) }    addAction(action, for: event)    return subject…
  17. 17

    Why does a Timer.publish countdown freeze while the user scrolls, and how do you fix it?

    The timer was scheduled in RunLoop.Mode.default, and while a finger drags a scroll view the main run loop runs in tracking mode, so timers in the default mode do not fire; schedule it with in: .common instead.

    // wrong: .default mode, stops while a scroll view is trackingTimer.publish(every: 1, on: .main, in: .default).autoconnect() // right: .common includes the tracking modeTimer.publish(every: 1, on: .main, in: .common)  .autoconnect()…
  18. 18

    How do Combine and async/await interoperate, and when is Combine still the better tool?

    A publisher becomes an AsyncSequence through .values, and async work becomes a publisher through `Deferred { Future { ...

    // Publisher -> async: any publisher is an AsyncSequence via .valuesTask {  for await query in viewModel.$query.values {    print(query)  }}…
  19. 19

    A Combine pipeline emits twice and then goes quiet. How do you find where the events stop?

    Tag each stage with print("name") or handleEvents and read the lifecycle log: the first stage that stops logging values is where the chain died, and its last event says how.

    $query  .print("1-query")  .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)  .map { api.search($0).print("2-request") }  .switchToLatest()  .print("3-results")…
  20. 20

    One expensive publisher has three subscribers. How many times does the work run, and how do you share it?

    Three times, because each subscription runs the whole chain again; share(), multicast or makeConnectable() make the subscribers use one upstream execution.

    let request = URLSession.shared.dataTaskPublisher(for: url)  .map(\.data)  .replaceError(with: Data())  .handleEvents(receiveSubscription: { _ in print("network hit") }) let a = request.sink { _ in }…
  21. 21

    What is backpressure in Combine, and why doesn't it protect you when a sink meets a fast producer?

    Backpressure means the subscriber tells the publisher how many values it can take through Subscribers.Demand; sink and assign request .unlimited up front, so the protocol is effectively switched off and every value is pushed straight through.

    // wrong: unlimited demand, one main-queue block per messagesocket.messages  .receive(on: DispatchQueue.main)  .sink { [weak self] in self?.rows.append($0) }  .store(in: &bag)// right: batch off the main thread, about 10 UI updates a second…
  22. 22

    How do you write a custom Combine operator, and when do you need your own Publisher and Subscription?

    A custom operator is almost always an extension on Publisher that composes existing operators and returns AnyPublisher or some Publisher; a hand-written Publisher with its own Subscription is worth it only for a new source that must honour demand and cancellation itself.

    // Most custom operators are compositionsextension Publisher where Output == String, Failure == Never {  func settledQuery() -> AnyPublisher<String, Never> {    debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)      .removeDuplicates().eraseToAnyPublisher()  }…
  23. 23

    How do you unit-test a pipeline that uses debounce without sleeping in the test?

    Inject the scheduler instead of hard-coding DispatchQueue.main, and pass a test scheduler with a virtual clock that the test moves forward by hand.

    final class SearchViewModel {  @Published var query = ""  private(set) var results: [Match] = []  private var bag = Set<AnyCancellable>()   init(api: SearchAPI, scheduler: AnySchedulerOf<DispatchQueue>) {…
  24. 24

    What breaks when a Combine-heavy module moves to the Swift 6 language mode, and how do you fix it?

    Combine predates Sendable and actor isolation, so Swift 6 rejects values and closures that cross isolation boundaries through it, and it adds runtime checks that crash a main-actor sink fed from a background queue.

    @MainActorfinal class FeedViewModel {  private(set) var posts: [Post] = []  private var bag = Set<AnyCancellable>()   func start(_ updates: AnyPublisher<[Post], Never>) {…