No universal replacement in Combine

BehaviorRelay does not have one universal replacement in Combine, because the right choice depends on who owns the state and who is allowed to change it.

This is one of the first Combine state-management decisions teams hit during a BehaviorRelay migration.

Use @Published for owned state

Use @Published when the value belongs to one owning type and the rest of the app should only observe it.

That is the right default for most UI state:

  • selected tab
  • loading flag
  • button enabled state
  • search text
  • current filter

One object owns the value, updates it internally, and exposes read-only observation to everyone else.

Use CurrentValueSubject when the subject is part of the design

Use CurrentValueSubject when the subject itself is part of the design.

That usually means one of two things:

  • multiple collaborators need to push values into the same stream
  • you are bridging imperative events into Combine and need an explicit input surface

The common migration mistake

Replacing every BehaviorRelay with CurrentValueSubject just because both replay the latest value preserves replay, but often breaks ownership.

A value that used to be controlled by one type suddenly becomes writable from anywhere that can reach the subject.

The practical default

  • If it is owned state, start with @Published.
  • If you genuinely need a subject, use CurrentValueSubject.

Simplified pseudocode

This is simplified pseudocode, not production code.

import Combine

final class InboxViewModel: ObservableObject {
    @Published private(set) var selectedFilter = "all"
    let searchInput = CurrentValueSubject<String, Never>("")

    func selectUnread() {
        selectedFilter = "unread"
    }
}

selectedFilter is owned state, so the view model changes it internally.

searchInput is an explicit input surface, so a subject makes sense there.

Preserve who is in charge of the value

A good migration does not just preserve the latest value.

It preserves who is in charge of that value.

If this choice appeared during a broader RxSwift to Combine migration, the parent rule still comes first: preserve the behavior contract before you celebrate the syntax change.