PassthroughSubject vs. CurrentValueSubject explained

PassthroughSubject and CurrentValueSubject are two types from the Combine framework that conforms to the Subject protocol. Both are very similar but have a few keys differences that are important to know. You can see them as custom publishers, but the main difference is that they are a little easier to work with.

If you’re new to Combine, you might want first to read my article Getting started with Combine. If you have the basics at hand, you’re going to enjoy learning about PassthroughSubject and CurrentValueSubject, which can be useful in writing code solutions with Combine.

What is a PassthroughSubject?

A PassthroughSubject broadcasts elements to downstream subscribers and provides a convenient way to adapt existing imperative code to Combine. As the name suggests, this type of subject only passes through values meaning that it does not capture any state and will drop values if there aren’t any subscribers set.

How to use a PassthroughSubject?

A PassthroughSubject is initialized without any value. In the following example, we’re simulating a chatroom that can be closed manually or by an occurring error like a missing network connection.

struct ChatRoom {
    enum Error: Swift.Error {
        case missingConnection
    }
    let subject = PassthroughSubject<String, Error>()
    
    func simulateMessage() {
        subject.send("Hello!")
    }
    
    func simulateNetworkError() {
        subject.send(completion: .failure(.missingConnection))
    }
    
    func closeRoom() {
        subject.send("Chat room closed")
        subject.send(completion: .finished)
    }
}

You can use the subject to subscribe to received messages from the chatbox:

let chatRoom = ChatRoom()
chatRoom.subject.sink { completion in
    switch completion {
    case .finished:
        print("Received finished")
    case .failure(let error):
        print("Received error: \(error)")
    }
} receiveValue: { message in
    print("Received message: \(message)")
}

In a happy flow, the room would receive some messages and gets closed manually:

chatRoom.simulateMessage()
chatRoom.closeRoom()

// Received message: Hello!
// Received message: Chat room closed
// Received finished

However, it’s also possible that a network error is received. In this case, the subject is completed using an error:

chatRoom.simulateMessage()
chatRoom.simulateNetworkError()

// Received message: Hello!
// Received error: missingConnection

What is a CurrentValueSubject?

A CurrentValueSubject wraps a single value and publishes a new element whenever the value changes. A new element is published even if the updated value equals the current value. Unlike the PassthroughSubject, a CurrentValueSubject always holds a value. A new subscriber will directly receive the current value contained in the subject.

How to use a CurrentValueSubject?

A CurrentValueSubject is initialized with an initial value. Unlike with a PassthroughSubject, new subscribers will receive this initial value upon subscribing. In the following example, we’ve created an Uploader that initially holds the pending state:

struct Uploader {
    enum State {
        case pending, uploading, finished
    }
    
    enum Error: Swift.Error {
        case uploadFailed
    }
    
    let subject = CurrentValueSubject<State, Error>(.pending)
    
    func startUpload() {
        subject.send(.uploading)
    }
    
    func finishUpload() {
        subject.value = .finished
        subject.send(completion: .finished)
    }
    
    func failUpload() {
        subject.send(completion: .failure(.uploadFailed))
    }
}

The value of a CurrentValueSubject can be set using the send method or by directly assigning it to the value property. The subject can also be finished, either successfully or with an error.

Implementors can subscribe to events using the sink method:

let uploader = Uploader()
uploader.subject.sink { completion in
    switch completion {
    case .finished:
        print("Received finished")
    case .failure(let error):
        print("Received error: \(error)")
    }
} receiveValue: { message in
    print("Received message: \(message)")
}

Which in case of a successful upload, prints out all different states:

uploader.startUpload()
uploader.finishUpload()

// Received message: pending
// Received message: uploading
// Received message: finished
// Received finished

The pending state is the initial state set and is received directly upon subscribing. Once the upload completes, the final finished state is passed through, and the stream is closed through a finished event.

In case of a failed upload, the stream will be finished with an error:

uploader.startUpload()
uploader.failUpload()

// Received message: pending
// Received message: uploading
// Received error: uploadFailed

After either a regular finished event or a failure, the subject will pass no more values. This is due to the lifecycle of a subject.

Understanding the lifecycle of a subject

It’s important to understand the lifecycle of a subject. Whenever a finished event is received, the subject will no longer pass through any new values.

This can be demonstrated with the above example by trying to send a message after a network error occurred:

chatRoom.simulateMessage()
chatRoom.simulateNetworkError()
chatRoom.simulateMessage()

// Received message: Hello!
// Received error: missingConnection

The last message is no longer printed as the subscription is already closed due to the finished with error event.

When using either a PassthroughSubject or CurrentValueSubject, it’s important to think about the lifecycle and close a subject when it’s clear that there are no values to be expected anymore.

What are the difference between a PassthroughSubject and a CurrentValueSubject?

The above examples have demonstrated a few of the differences between a PassthroughSubject and a CurrentValueSubject. Though, it’s valuable to iterate over them again.

The main difference between both subjects is that a PassthroughSubject doesn’t have an initial value or a reference to the most recently published element. Therefore, new subscribers will only receive newly emitted events.

Both subjects are explained well by using an analogy:

  • A PassthroughSubject is like a doorbell push button
    When someone rings the bell, you’re only notified when you’re at home
  • A CurrentValueSubject is like a light switch
    When a light is turned on while you’re away, you’ll still notice it was turned on when you get back home.

Conclusion

Both PassthroughSubject and CurrentValueSubject are valuable parts of the Combine framework. Unlike custom publishers, they are relatively easy to work with and allow sending values over Combine streams. Subscribers will receive newly emitted values and can respond to them accordingly.

To read more about Swift Combine, take a look at my other Combine blog posts: