Home

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.