Understanding Subjects in Swift's Combine Framework - A Deep Dive into CurrentValueSubject and PassthroughSubject

The Combine framework introduces a powerful concept called Subjects, which serve as a bridge between imperative programming and reactive streams. In this comprehensive guide, we'll examine the two primary subject types that Apple provides and explore their practical applications in iOS development.

What Makes Subjects Special?

A Subject represents a specialized publisher that conforms to the Subject protocol within Combine. What distinguishes subjects from regular publishers is their .send(_:) method, which enables developers to manually inject values into a reactive stream. This capability makes subjects invaluable when you need to integrate traditional imperative code with Combine's reactive paradigm.

Subjects excel at distributing values to multiple subscribers simultaneously, making them excellent tools for connecting different parts of your reactive pipeline. Apple provides two fundamental subject implementations: CurrentValueSubject and PassthroughSubject, each serving distinct use cases.

CurrentValueSubject: Stateful Value Management

CurrentValueSubject requires initialization with a default value and maintains state by remembering the most recent value it has received. When you call send() on this subject, it both updates its internal value and broadcasts the change to all subscribers.

Consider this practical example:

let currentValueSubject = CurrentValueSubject<Int, Never>(1)
 
currentValueSubject.sink { int in
    print("first subscriber got \(int)")
}.store(in: &cancellables)
 
currentValueSubject.send(2)
 
currentValueSubject.sink { int in
    print("second subscriber got \(int)")
}.store(in: &cancellables)
 
currentValueSubject.send(3)

This code produces the following output:

first subscriber got 1
first subscriber got 2
second subscriber got 2
second subscriber got 3
first subscriber got 3

Notice how the second subscriber immediately receives the current value (2) upon subscription, demonstrating the stateful nature of CurrentValueSubject.

PassthroughSubject: Lightweight Event Broadcasting

PassthroughSubject operates without requiring an initial value and doesn't maintain internal state. True to its name, it simply passes events through to subscribers when send() is invoked.

Here's how it works:

let passthroughSubject = PassthroughSubject<Int, Never>()
 
passthroughSubject.sink { int in
    print("first subscriber got \(int)")
}.store(in: &cancellables)
 
passthroughSubject.send(2)
 
passthroughSubject.sink { int in
    print("second subscriber got \(int)")
}.store(in: &cancellables)
 
passthroughSubject.send(3)

The resulting output:

first subscriber got 2
second subscriber got 3
first subscriber got 3

Observe that the second subscriber only receives the value 3, missing the earlier emission of 2 because it wasn't subscribed when that event occurred.

Comparing the Two Approaches

CurrentValueSubjectPassthroughSubject
Maintains stateStateless operation
Requires initial valueNo initial value needed
Stores latest published valueNo value storage
Emits events and retains valueOnly emits events
Provides .value property accessNo direct value access

Strategic Applications of Subjects

Testing with PassthroughSubject

PassthroughSubject proves invaluable in unit testing scenarios. You can inject it as a publisher dependency and manually control event emission during tests.

Here's a testing example that validates a function filtering numbers divisible by three:

func test_combine() {
    let passthroughSubject = PassthroughSubject<Int, Never>()
    let divisibleByThree = divisibleByThree(input: passthroughSubject.eraseToAnyPublisher())
    let expectation = XCTestExpectation()
 
    divisibleByThree.collect(3).sink { ints in
        XCTAssertEqual([3, 6, 9], ints)
        expectation.fulfill()
    }.store(in: &cancellables)
 
    passthroughSubject.send(1)
    passthroughSubject.send(2)
    passthroughSubject.send(3)
    passthroughSubject.send(6)
    passthroughSubject.send(9)
    passthroughSubject.send(10)
 
    wait(for: [expectation], timeout: 1)
}

Bridging Legacy APIs

Subjects excel at connecting traditional delegate-based APIs with Combine streams. For instance, when working with UISearchBar, you might implement the delegate protocol and forward text changes through a PassthroughSubject:

extension ViewController: UISearchBarDelegate {
    func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
        textSubject.send(searchBar.text ?? "")
    }
}

This pattern enables seamless integration between UIKit's imperative APIs and Combine's reactive architecture.

When to Avoid Subjects

While subjects are powerful, they should be used judiciously. Overusing subjects can make data flow less transparent and harder to reason about. Consider this problematic example:

final class ViewModel {
    var cancellables: Set<AnyCancellable> = []
    let currentValueSubject = CurrentValueSubject<String?, Never>("Placeholder")
 
    init() {
        API.getUserID().sink { id in
            self.currentValueSubject.send(id)
        }.store(in: &cancellables)
    }
}

This approach unnecessarily introduces a subject when you could use the assign operator directly:

final class ViewModel {
    var cancellables: Set<AnyCancellable> = []
    @Published var text: String? = "Placeholder"
 
    init() {
        API.getUserID()
            .assign(to: \.text, on: self)
            .store(in: &cancellables)
    }
}

For even better testability and functional composition, consider this approach:

func viewModel(api: (URL) -> AnyPublisher<String?, Never> = API.getUserID) -> AnyPublisher<String?, Never> {
    api(API.users)
}

This functional approach eliminates the need for subjects entirely while providing better testability through dependency injection.

Key Takeaways

Subjects in Combine provide essential functionality for managing reactive data streams, with CurrentValueSubject offering stateful value management and PassthroughSubject providing lightweight event broadcasting. While they're invaluable for testing scenarios and bridging imperative code, they should be used sparingly to maintain clear, understandable data flow patterns. The goal is to leverage Combine's declarative nature while using subjects only when truly necessary for integration or testing purposes.