Combine
Apple's reactive framework — publishers, subscribers, operators, and MVVM binding.
13 questions
JuniorTheoryVery commonWhat are a publisher and a subscriber in Combine, and how do they relate?
What are a publisher and a subscriber in Combine, and how do they relate?
A publisher emits a stream of values over time, plus a completion or failure. A subscriber requests and receives them, and the subscription controls demand, so values flow only when requested.
Common mistakes
- ✗Thinking a publisher pushes values eagerly regardless of subscriber demand
- ✗Confusing the subscriber with the subscription object that manages the lifecycle
- ✗Assuming every publisher delivers exactly one value rather than a stream
Follow-up questions
- →What does the Subscription object control between publisher and subscriber?
- →How does a publisher signal that the stream finished versus failed?
JuniorTheoryVery commonWhat do @Published, CurrentValueSubject, and AnyCancellable do in Combine?
What do @Published, CurrentValueSubject, and AnyCancellable do in Combine?
@Published wraps a property so assignments emit on a publisher reached via $. CurrentValueSubject also holds and replays its current value. Retain the AnyCancellable to keep a subscription alive.
Common mistakes
- ✗Forgetting the
$prefix to reach a@Publishedproperty's publisher - ✗Not retaining
AnyCancellable, so the subscription is cancelled immediately - ✗Confusing
CurrentValueSubject(replays current value) withPassthroughSubject(no stored value)
Follow-up questions
- →When would you choose
CurrentValueSubjectoverPassthroughSubject? - →Where do you typically store cancellables in a view model?
JuniorTheoryCommonWhy does every new subscriber to a dataTaskPublisher fire its own request, and what does share() change?
Why does every new subscriber to a dataTaskPublisher fire its own request, and what does share() change?
dataTaskPublisher is a cold publisher: each subscription, held by its AnyCancellable, re-runs and fires a fresh request. share() makes it hot, multicasting one subscription to all subscribers, so a late subscriber misses earlier values.
Common mistakes
- ✗Assuming a cold publisher shares one request across subscribers
- ✗Thinking
share()replays or caches values for late subscribers - ✗Confusing
share()(multicast) with a stored, replayed value
Follow-up questions
- →How does
share()differ frommulticast(_:)with aCurrentValueSubject? - →What exactly does a late subscriber to a
share()d publisher miss?
JuniorCodeCommonBuild a small pipeline with map, filter, compactMap, and scan, and say what each emits
Build a small pipeline with map, filter, compactMap, and scan, and say what each emits
map transforms each element one-to-one and never drops. compactMap maps then drops any nil, so non-numeric strings vanish. filter forwards only elements passing a predicate. scan keeps an accumulator and emits the running total after every element.
Common mistakes
- ✗Confusing
compactMap(dropsnil) withmap(transforms 1:1) - ✗Thinking
scanemits only once at completion, not per element - ✗Assuming
filtertransforms values rather than passing them through
Follow-up questions
- →How does
scandiffer fromreducein what it emits? - →When would you reach for
compactMapovermapplusfilter?
JuniorTheoryCommonPassthroughSubject vs CurrentValueSubject — which gives a late subscriber a value on subscribe, and why does it matter for UI?
PassthroughSubject vs CurrentValueSubject — which gives a late subscriber a value on subscribe, and why does it matter for UI?
CurrentValueSubject stores a current value and replays it to each new subscriber, so a late subscriber immediately gets the latest state. PassthroughSubject stores nothing and forwards only values sent after a subscriber attaches.
Common mistakes
- ✗Assuming
PassthroughSubjectreplays its last value like a stored variable - ✗Thinking a late subscriber to
CurrentValueSubjectmust wait for the next emission - ✗Treating the two subjects as interchangeable when seeding initial UI state
Follow-up questions
- →How do you seed initial UI state when all you have is a
PassthroughSubject? - →What value does a
CurrentValueSubjectemit the moment a subscriber attaches?
MiddleDebuggingCommonA pipeline goes silent after one network error — why, and how do catch/retry/replaceError keep it alive?
A pipeline goes silent after one network error — why, and how do catch/retry/replaceError keep it alive?
A .failure completion is terminal — it tears down the subscription, so nothing flows afterward. Put retry, catch, or replaceError on the inner publisher inside flatMap so the error is absorbed there and the outer $query stream keeps emitting.
Common mistakes
- ✗Believing a
.failurecompletion is recoverable without an operator - ✗Placing
catch/retryon the outer chain instead of the inner publisher - ✗Assuming an empty
receiveCompletionclosure keeps the stream alive
Follow-up questions
- →Why does
catchon the inner publisher protect the outer stream? - →When would
retryalone be worse thanretrypluscatch?
MiddleCodeCommonBuild a search pipeline — debounce → removeDuplicates → switchToLatest — bound to the UI
Build a search pipeline — debounce → removeDuplicates → switchToLatest — bound to the UI
Chain $query.debounce(...), removeDuplicates(), then map each query to search(_:) and switchToLatest() so a newer query cancels the in-flight request. Finish with receive(on:) on main and assign(to: &$results).
Common mistakes
- ✗Using
flatMapwhereswitchToLatestis needed, so stale results overwrite fresh ones - ✗Mutating UI state off the main thread without
receive(on:) - ✗Dropping
debounce, firing a request on every keystroke
Follow-up questions
- →Why does
switchToLatestcancel the previous request butflatMapdoes not? - →Where would you place
receive(on:)relative toassign?
MiddleCodeCommonWrap a one-shot callback API in a Future, and a delegate callback stream in a subject-backed publisher
Wrap a one-shot callback API in a Future, and a delegate callback stream in a subject-backed publisher
Use a Future for one-shot: run loadUser and pass its Result to promise; it fires once and caches it. For the delegate, hold a private PassthroughSubject, send(_:) per callback, and expose eraseToAnyPublisher() — read-only for callers.
Common mistakes
- ✗Reaching for a
Futurefor a multi-value delegate stream - ✗Exposing the
PassthroughSubjectinstead of an erased read-only publisher - ✗Expecting a
Futureto re-run its closure on each subscription
Follow-up questions
- →Why does a
Futurecache and replay its single result? - →How would you back the delegate stream with
CurrentValueSubjectinstead?
MiddleDebuggingCommonUI updates off the main thread — is the fix receive(on:) or subscribe(on:)? Explain what each one moves.
UI updates off the main thread — is the fix receive(on:) or subscribe(on:)? Explain what each one moves.
Use receive(on: DispatchQueue.main) before sink. receive(on:) moves values, completion, and the subscriber downstream onto that scheduler. subscribe(on:) only sets where subscription and upstream work start, so it can't fix UI delivery.
Common mistakes
- ✗Thinking
subscribe(on:)controls where values are delivered - ✗Placing
receive(on:)upstream instead of just beforesink - ✗Assuming
sinkruns its value closure on the main thread by default
Follow-up questions
- →What happens if you call
receive(on:)twice at different points? - →Why does
subscribe(on:)not affect the subscriber's thread?
MiddleDebuggingCommon.sink { self.name = $0 } stored in self.cancellables — why does the view model never dealloc, and what is the fix?
.sink { self.name = $0 } stored in self.cancellables — why does the view model never dealloc, and what is the fix?
self owns cancellables, which owns the AnyCancellable, whose sink closure captures self strongly — a retain cycle, so self never deallocs. Fix it with a [weak self] capture list, or use assign(to: &$name), which captures no self.
Common mistakes
- ✗Capturing
selfstrongly in asinkclosure stored onself - ✗Blaming
store(in:)or@Publishedinstead of the closure capture - ✗Forgetting that
assign(to: &$x)avoids capturingself
Follow-up questions
- →Why does
assign(to: &$name)avoid the retain cycle? - →When is
[unowned self]unsafe here compared to[weak self]?
MiddleDesignOccasionalYou maintain a search pipeline built with the reactive framework Combine (debounce → API call → receive(on:) → published results), and its tests are flaky. They call sleep to wait out the debounce and the async delivery, so they pass on a fast CI machine and time out on a slow one. Describe how you would make the pipeline deterministically testable without any real waiting: how you would inject the scheduler so tests advance virtual time instead of wall-clock time, how the production code should receive that scheduler, what you would substitute for the live network call, and how you would assert on the exact sequence of values (and completion) the pipeline emits for a given input. Explain the trade-off between abstracting the scheduler behind a protocol and depending on a concrete test scheduler type directly.
You maintain a search pipeline built with the reactive framework Combine (debounce → API call → receive(on:) → published results), and its tests are flaky. They call sleep to wait out the debounce and the async delivery, so they pass on a fast CI machine and time out on a slow one. Describe how you would make the pipeline deterministically testable without any real waiting: how you would inject the scheduler so tests advance virtual time instead of wall-clock time, how the production code should receive that scheduler, what you would substitute for the live network call, and how you would assert on the exact sequence of values (and completion) the pipeline emits for a given input. Explain the trade-off between abstracting the scheduler behind a protocol and depending on a concrete test scheduler type directly.
Inject the scheduler behind a protocol (or AnyScheduler): production uses DispatchQueue.main, tests use a controllable scheduler advancing virtual time — no sleep. Stub the network with a fixed publisher and assert the exact value sequence and completion.
Common mistakes
- ✗Using
sleepor timeouts instead of a controllable scheduler - ✗Testing against the real network instead of a stub publisher
- ✗Asserting only the final value, not the emitted sequence
Follow-up questions
- →How does a test scheduler advance virtual time on demand?
- →Why assert the whole sequence rather than just the last value?
SeniorTheoryOccasionalFor a new feature, pick the reactive framework Combine or async/await + AsyncSequence — what does Combine still do best?
For a new feature, pick the reactive framework Combine or async/await + AsyncSequence — what does Combine still do best?
Use async/await for linear request/response flows — simpler, no cancellables, native cancellation. Keep Combine for declarative pipelines with debounce/combineLatest, demand-based backpressure, and @Published binding; bridge via .values.
Common mistakes
- ✗Believing
async/awaitfully replacesCombinefor every feature - ✗Assuming
AsyncSequenceoffers the same multi-operator pipeline as Combine - ✗Overlooking that
Combinegives demand-based backpressure thatawaitdoes not
Follow-up questions
- →How do you turn a publisher into an
AsyncSequence? - →Which model gives you demand-based backpressure that
awaitlacks?
SeniorCodeOccasionalRefactor a UIKit MVVM ProductViewModel to Combine bindings
Refactor a UIKit MVVM ProductViewModel to Combine bindings
Expose state as @Published properties (or CurrentValueSubject) instead of the custom Observable, and drop the unused context: UIViewController so the view model no longer depends on UIKit. Inject the repositories as protocols, and let the controller subscribe and store its AnyCancellables.
Common mistakes
- ✗Subscribing without storing cancellables, so bindings are torn down immediately
- ✗Keeping repository construction inline in init instead of injecting protocols
Follow-up questions
- →How would protocol-based repository injection make this view model unit-testable?
- →Where should the button-title formatting live, and why not in the controller's closure?