Subjects
The bridge from imperative code, and the part that gets over-used
A subject is a publisher you can push values into by hand. It is the door between code that calls functions and code that composes streams.
let taps = PassthroughSubject<Void, Never>()
@IBAction func buttonTapped() {
taps.send(())
}
Two kinds, differing in one respect: whether a new subscriber receives anything immediately.
PassthroughSubject holds nothing. Subscribe after three values were sent and you have missed
all three. Right for events — a tap, a notification, “the user asked to refresh”.
CurrentValueSubject holds its latest value and replays it to every new subscriber. Right for
state — the current user, whether syncing is on, the selected filter. Its .value is also readable
and writable directly, which makes it a bridge in both directions.
let isOnline = CurrentValueSubject<Bool, Never>(true)
isOnline.value = false // sends to subscribers as well
print(isOnline.value) // read without subscribing
The rule that decides it: if a subscriber arriving late needs to know the current situation, it is
state and you want CurrentValueSubject. If arriving late means the moment has passed, it is an
event and you want PassthroughSubject.
Completion is permanent
Send .finished or a failure and the subject is done. Every later send(_:) is silently ignored,
and every new subscriber receives the completion immediately.
subject.send(completion: .finished)
subject.send(42) // ignored, no warning, no crash
This causes a specific and confusing bug: a subject wired to an error path finishes on the first error, and the feature stops working with no further errors, because the pipeline that would report them is closed. If a subject represents an ongoing source, either never complete it, or model errors as values.
enum LoadState { case loading, loaded([Item]), failed(String) }
let state = CurrentValueSubject<LoadState, Never>(.loading)
Failure == Never with the error carried as a case is the shape that survives contact with a real
app, and it is what the SwiftUI-facing side of most pipelines should look like.
Where subjects get over-used
A subject in the middle of a pipeline is usually a sign that a chain was broken apart and stitched back together imperatively:
// smell: subscribing to one stream in order to push into another
upstream
.sink { [weak self] value in
self?.relay.send(value * 2)
}
.store(in: &cancellables)
That is .map { $0 * 2 } with extra steps, plus a lost error path, plus a lifetime you now manage
by hand. Whenever a sink exists only to send into a subject, the two halves belong to one chain.
Warning
Subjects are also where thread-safety quietly disappears. send(_:) from two queues at once is
not safe, and the failure is a corrupted subscription rather than a clean crash. If a subject is
fed from more than one place, decide on one queue and use receive(on:) before it, or hold it
behind an actor.
Exposing a subject safely
A subject that is public or internal can be sent into — and completed — by anyone. Keep the
subject private and publish a read-only view:
final class SessionStore {
private let stateSubject = CurrentValueSubject<SessionState, Never>(.signedOut)
var state: AnyPublisher<SessionState, Never> {
stateSubject.eraseToAnyPublisher()
}
var currentState: SessionState { stateSubject.value }
func signIn(_ user: User) {
stateSubject.send(.signedIn(user))
}
}
Callers can observe and read; only SessionStore can change or complete it. This is the shape worth
defaulting to — an exposed subject is mutable shared state with a nicer name, and it will eventually
be completed by a piece of code you did not expect to have that power.
The @Published shortcut
@Published is effectively a CurrentValueSubject generated for you, projected with $:
final class Model: ObservableObject {
@Published var query = ""
}
model.$query
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.sink { … }
One catch worth knowing: $query fires in willSet, so subscribers see the change before the
property has it. Reading model.query inside that sink gives you the old value. Use the value the
closure was handed, not the property.