niksativa/combineext
**CombineExt** is a lightweight, extensible Combine utility library designed for UIKit developers. It brings a SwiftUI-style reactive approach to state and action management, while keeping UIKit's flexibility. Ideal for MVVM or MVI architecture, it simplifies your reactive stack
π¦ Installation
Add via Swift Package Manager:
https://github.com/NikSativa/CombineExt.gitOr add the collection of related packages:
https://swiftpackageindex.com/NikSativa/collection.jsonπ Features
- Reactive State Management:
@ManagedState,@UIState,@UIBinding,@ValueSubjectfor different reactive patterns - Smart Property Wrappers:
@IgnoredStatefor transient data that doesn't affect equality/hashing - Thread Safety:
withLockfor atomic operations, preventing race conditions in concurrent code - Safe Operations: Safe collection access, nil filtering, and crash prevention
- Advanced Binding: Rich binding DSL with both
WritableKeyPathandKeyPathsupport - Extended Publishers:
CombineLatest5/6,Zip5/6for complex data flows - State Tracking:
DiffedValuefor state transitions with old and new values - Publisher Extensions:
filterNils(),mapVoid(), and advanced binding methods - Declarative DSL:
SubscriptionBuilderandAnyTokenBuilderfor clean reactive code - Dynamic Callable: Property wrapper syntax for intuitive API usage
- Utility Functions:
differs()for efficient value comparison with key paths - Comprehensive Testing: 216+ tests ensuring reliability and stability
π§ `ManagedState` for Reactive Models
Encapsulate a stateful model and its logic reactively:
struct CounterModel: BehavioralStateContract {
var displayText: String = "0"
var count: Int = 0
var isOdd: Bool = false
/// Applies internal consistency rules to the model.
///
/// This method is invoked automatically after state mutations to derive
/// secondary values based on core model properties.
///
/// For example, `isOdd` is updated to reflect whether the `count` value is odd.
mutating func applyRules() {
isOdd = count % 2 != 0 // variant 1
}
/// Defines reactive state bindings using Combine pipelines.
///
/// This method binds specific value changes from the model to logic that updates
/// other parts of the model. It enables automatic propagation of computed state
/// whenever source properties change.
///
/// - Parameter state: A publisher emitting diffs of model state.
///
/// ### Example
/// Use `bindDiffed(to:)` to react to value changes using both the old and new state.
@SubscriptionBuilder
static func applyBindingRules(to state: RulesPublisher) -> [AnyCancellable] {
// variant 2: `pair` contains `old` & `new` states, so easily to handle changes
state.bindDiffed(to: \.count) { pair in
pair.new.displayText = "was: \(pair.old.count) - now: \(pair.new.count)"
}
}
/// Registers external notification rules that affect the model.
///
/// Use this to listen to app-wide notifications (e.g., `NotificationCenter`) and apply side effects to the model state.
///
/// - Parameter state: A reference to the managed state.
/// - Returns: An array of tokens keeping observers alive.
@AnyTokenBuilder<Any>
static func applyAnyRules(to state: UIBinding<Self>) -> [Any] {
// External observers (e.g., NotificationCenter)
[]
}
}Usage
@ManagedState var model = CounterModel()
$model.sink { print("Count:", $0.count) }.store(in: &cancellables)
model.count += 1Dynamic Callable Support
@ManagedState supports dynamic callable syntax via projected value:
@ManagedState var model = CounterModel()
// Access entire value (using $ projected value)
let binding = $model() // Returns binding to entire CounterModel
// Access specific properties (using $ projected value)
let countBinding = $model(\.count) // Returns binding to count property
let displayBinding = $model(\.displayText) // Returns binding to displayText property
// Update through bindings (automatically applies rules)
countBinding.wrappedValue = 10
displayBinding.wrappedValue = "Updated"πͺ `@UIState` & `@UIBinding`
Bind and observe nested values with minimal boilerplate:
struct ViewState: Equatable { var isOn: Bool }
@UIState var state = ViewState(isOn: false)
$state.isOn
.sink { print("Toggle is now:", $0) }
.store(in: &cancellables)
state.isOn = trueDynamic Callable Support
@UIState and @UIBinding support dynamic callable syntax via projected value:
struct ViewState: Equatable { var isOn: Bool }
@UIState var state = ViewState(isOn: false)
// Access entire value (using $ projected value)
let binding = $state() // Returns binding to entire ViewState
// Access specific properties (using $ projected value)
let isOnBinding = $state(\.isOn) // Returns binding to isOn property
// Update through bindings
isOnBinding.wrappedValue = trueNote: The examples above show @UIState as a property wrapper. Always use @UIState syntax, not UIState(...) initialization.
Safe Collection Access
@UIState var names: [String?] = ["A", "B"]
let name = $names.safe(10, default: "Fallback")
print(name.wrappedValue) // "Fallback"
let safeItem = $names.safe(1)
print(safeItem.wrappedValue ?? "nil") // "B"UIBinding as Property Wrapper
UIBinding can be used as a property wrapper. You typically initialize it by assigning a binding from @UIState or @ManagedState:
Example:
struct State: Equatable { var name: String }
@UIState var state = State(name: "name")
final class MyView: UIView {
@UIBinding(.placeholder) private var name: String
private var cancellables: Set<AnyCancellable> = []
func configure(withName binding: UIBinding<String>) {
_name = binding
// Subscribe to changes (receives DiffedValue)
$name
.sink { diff in
print("Name changed to: \(diff.new)")
}
.store(in: &cancellables)
// Or subscribe to just new values
$name.publisher
.sink { newName in
print("Name changed to: \(newName)")
}
.store(in: &cancellables)
}
func buttonTapped() {
name = "new name" // Direct access to wrapped value
}
}
let view = MyView(frame: .zero)
view.configure(withName: $state.name)
view.buttonTapped()Using with uninitialized binding:
final class MyView: UIView {
@UIBinding(.placeholder) private var name: String
private var cancellables: Set<AnyCancellable> = []
func configure(withName binding: UIBinding<String>) {
_name = binding
}
}
let view = MyView(frame: .zero)
struct State: Equatable { var name: String }
@UIState var state = State(name: "test")
view.configure(withName: $state.name)Using with constant binding:
You can assign a constant binding to a property wrapper using _binding = .constant(...):
final class MyView: UIView {
@UIBinding(.placeholder) private var name: String
private var cancellables: Set<AnyCancellable> = []
func configure(withName binding: UIBinding<String>) {
_name = binding
}
}
let view = MyView(frame: .zero)
view.configure(withName: .constant("test"))Direct usage (not as property wrapper):
For read-only bindings that never emit changes, use the static constant(_:) method:
// Create a constant binding for a fixed title
let titleBinding = UIBinding<String>.constant("Fixed Title")
// Reading works
print(titleBinding.wrappedValue) // Prints: "Fixed Title"
// Setting is ignored
titleBinding.wrappedValue = "New Title"
print(titleBinding.wrappedValue) // Still prints: "Fixed Title"
// Subscribe to changes (receives DiffedValue)
titleBinding
.sink { diff in
print("Changed from \(diff.old ?? "nil") to \(diff.new)")
}
.store(in: &cancellables)
// Or subscribe to just new values
titleBinding.publisher
.sink { newTitle in
print("New title: \(newTitle)")
}
.store(in: &cancellables)
// Useful for preview or placeholder data
struct ContentView: View {
var binding: UIBinding<String>
static var previews: some View {
ContentView(binding: .constant("Preview Data"))
}
}Implementation:
/// Creates a constant binding that cannot be modified.
///
/// Use this method to create a read-only binding that always returns the same value.
/// Any attempts to set a new value will be ignored.
static func constant(_ value: Value) -> Self {
return .init(get: { value },
set: { new in Swift.print("Attempted to set on constant binding \(new)") })
}Custom Extensions
You can extend UIBinding with additional functionality as needed for your project. The library provides the core functionality, but you're free to add convenience extensions that fit your specific use cases.
Example: ExpressibleByNilLiteral Support
If you need to initialize UIBinding with nil for optional types, you can add this extension:
extension UIBinding where Value: ExpressibleByNilLiteral {
public init(nilLiteral: ()) {
self = .constant(nil)
}
}
// Usage:
final class MyView: UIView {
@UIBinding(.placeholder) private var optionalName: String?
}Note: This extension is not included in the library by default, as it may not be needed for all projects. Add it only if it fits your use case.
π `@ValueSubject`
A mutable, shared, observable value reference:
struct Form: Equatable { var name: String }
@ValueSubject var form = Form(name: "")
$form
.map(\.name)
.sink { print("Name changed to:", $0) }
.store(in: &cancellables)
form.name = "Nik"Dynamic Callable Support
@ValueSubject supports dynamic callable syntax via projected value:
struct User: Equatable {
var name: String = "John"
var age: Int = 25
}
@ValueSubject var user = User()
let binding = $user
// Subscribe to changes
$user
.sink { _ in }
.store(in: &cancellables)
// Update values directly
user.name = "Jane"
user.age = 30π― `@IgnoredState` for Transient Data
Use @IgnoredState to wrap values that should not affect equality or hashing:
struct DataCache: Equatable {
static func == (lhs: DataCache, rhs: DataCache) -> Bool { true }
}
struct ViewModel: Equatable {
var title: String = "Hello"
@IgnoredState var cache = DataCache()
@IgnoredState var timer: Timer?
// Only `title` affects equality, `cache` and `timer` are ignored
}
let vm1 = ViewModel()
var vm2 = ViewModel()
vm2.cache = DataCache() // Different cache
print(vm1 == vm2) // true - cache is ignoredDynamic Callable Support
@IgnoredState supports dynamic callable syntax for closures:
// Wrapping closures - enables direct call syntax
@IgnoredState var formatter: (Double) -> String = { value in
let formatter = NumberFormatter()
formatter.numberStyle = .currency
return formatter.string(from: NSNumber(value: value)) ?? "\(value)"
}
// Call the wrapped closure directly
let price = formatter(99.99) // "$99.99"
// Counter example
@IgnoredState var counter: () -> Int = {
var count = 0
count += 1
return count
}
print(counter()) // 1
print(counter()) // 2With Custom IDs
struct Model: Hashable {
@IgnoredState(id: 1) var cache1 = Data()
@IgnoredState(id: 2) var cache2 = Data()
// Different IDs make them distinct for hashing
}π `DiffedValue` for State Transitions
Track both old and new values during state changes:
@UIState var counter = 0
$counter.publisher
.sink { diff in
print("Changed from \(diff.old ?? 0) to \(diff.new)")
// diff.old is nil on first emission
}
.store(in: &cancellables)
counter = 5 // Prints: "Changed from 0 to 5"Dynamic Member Access
struct User: Equatable { var name: String; var age: Int }
@UIState var user = User(name: "Alice", age: 30)
$user.publisher
.sink { diff in
let _ = diff.name // Direct property access
let _ = diff.age
}
.store(in: &cancellables)π§ Publisher Extensions
Filtering and Mapping
// Filter out nil values
Just<String?>(nil)
.filterNils()
.sink { print($0) } // Won't be called
Just("Hello")
.filterNils()
.sink { print($0) } // Prints "Hello"
// Map to Void for side effects
button.tapPublisher
.mapVoid()
.sink { print("Button tapped") }
.store(in: &cancellables)Advanced Binding Methods
struct Profile: Equatable {
var name: String = ""
}
struct Model: BehavioralStateContract {
var username: String = ""
var count: Int = 0
var profile: Profile = Profile()
var readOnlyProperty: String = ""
var computedProperty: String = ""
mutating func applyRules() {}
@SubscriptionBuilder
static func applyBindingRules(to state: RulesPublisher) -> [AnyCancellable] { [] }
@AnyTokenBuilder<Any>
static func applyAnyRules(to state: UIBinding<Self>) -> [Any] { [] }
}
@UIState var state = Model()
// Bind to specific property changes (WritableKeyPath)
$state.publisher
.bind(to: \Model.username) { model in
model.username = "updated"
}
.store(in: &cancellables)
// Bind with both old and new values (WritableKeyPath)
$state.publisher
.bindDiffed(to: \Model.count) { model, diff in
print("Count changed from \(diff.old ?? 0) to \(diff.new)")
}
.store(in: &cancellables)
// Bind to nested properties (WritableKeyPath)
$state.publisher
.bind(to: \Model.profile.name) { name in
name = name.uppercased()
}
.store(in: &cancellables)
// Read-only bindings (KeyPath) - for observing without modification
$state.publisher
.bind(to: \Model.readOnlyProperty) { value in
print("Read-only property changed to: \(value)")
}
.store(in: &cancellables)
// Read-only diffed bindings (KeyPath)
$state.publisher
.bindDiffed(to: \Model.computedProperty) { model, diff in
print("Computed property changed from \(diff.old ?? "nil") to \(diff.new)")
}
.store(in: &cancellables)π Extended Combine Publishers
CombineLatest5 and CombineLatest6
let name = PassthroughSubject<String, Never>()
let age = PassthroughSubject<Int, Never>()
let email = PassthroughSubject<String, Never>()
let isActive = PassthroughSubject<Bool, Never>()
let lastLogin = PassthroughSubject<Date, Never>()
Publishers.CombineLatest5(name, age, email, isActive, lastLogin)
.sink { name, age, email, isActive, lastLogin in
print("User: \(name), \(age), \(email), active: \(isActive)")
}
.store(in: &cancellables)Zip5 and Zip6
let step1 = PassthroughSubject<String, Never>()
let step2 = PassthroughSubject<String, Never>()
let step3 = PassthroughSubject<String, Never>()
let step4 = PassthroughSubject<String, Never>()
let step5 = PassthroughSubject<String, Never>()
Publishers.Zip5(step1, step2, step3, step4, step5)
.sink { step1, step2, step3, step4, step5 in
print("All steps completed: \([step1, step2, step3, step4, step5])")
}
.store(in: &cancellables)π‘οΈ Safe Collection Access
Access array elements safely without crashes:
@UIState var items = ["A", "B", "C"]
// Safe access with default value
let item = $items.safe(10, default: "Not Found")
print(item.wrappedValue) // "Not Found"
// Safe access using current value as default
let safeItem = $items.safe(1)
print(safeItem.wrappedValue) // "B"
// Unsafe access (will crash if out of bounds)
let unsafeItem = $items.unsafe(1)
print(unsafeItem.wrappedValue) // "B"
// Create bindings for all array elements
let bindings = $items.bindingArray()
bindings[0].wrappedValue = "Updated"π οΈ Utility Functions
differs() for Efficient Value Comparison
Compare values at specific key paths efficiently with inlined performance:
struct Person {
let name: String
let age: Int
}
let person1 = Person(name: "Alice", age: 30)
let person2 = Person(name: "Bob", age: 25)
// Check if specific properties differ
let nameDiffers = differs(lhs: person1, rhs: person2, keyPath: \.name) // true
let ageDiffers = differs(lhs: person1, rhs: person2, keyPath: \.age) // truePerformance Note: This function is marked with @inline(__always) for optimal performance in reactive scenarios where it's called frequently.
ποΈ Helper Types
EventSubject and ActionSubject
// For general events
let messageSubject: EventSubject<String> = .init()
messageSubject.send("Hello World")
// For simple actions (Void events)
let didTapButton = ActionSubject()
didTapButton.send(()) // Trigger actionSafeBinding Protocol
All reactive wrappers conform to SafeBinding for consistent API:
struct State: Equatable { var value: String = "" }
@UIState var uiState = State()
@ValueSubject var valueSubject = State()
func observeState(_ binding: some SafeBinding) {
binding.justNew()
.sink { newValue in
print("New value: \(newValue)")
}
.store(in: &cancellables)
}
// Works with UIState (conforms to SafeBinding)
observeState($uiState)
// ValueSubject doesn't conform to SafeBinding, so we test it separately
$valueSubject
.sink { _ in }
.store(in: &cancellables)π§± MVVM Example
ViewModel
struct LoginState: BehavioralStateContract {
var username: String = ""
var password: String = ""
var status: String = ""
@IgnoredState var isLoading = false
mutating func applyRules() {
// Auto-validate on changes
if username.isEmpty || password.isEmpty {
status = "Please fill all fields"
} else {
status = "Ready to login"
}
}
@SubscriptionBuilder
static func applyBindingRules(to state: RulesPublisher) -> [AnyCancellable] {
// React to username changes
state.bind(to: \.username) { model in
model.status = "Username updated"
}
// React to password changes with diff
state.bindDiffed(to: \.password) { model, diff in
print("Password changed from \(diff.old?.count ?? 0) to \(diff.new.count) characters")
}
}
}
final class LoginViewModel: ObservableObject {
@ManagedState var state = LoginState()
private var cancellables = Set<AnyCancellable>()
init() {
// Observe state changes
$state
.sink { (diff: DiffedValue<LoginState>) in
print("State changed from \(diff.old?.status ?? "nil") to \(diff.new.status)")
}
.store(in: &cancellables)
}
}ViewController
final class LoginVC: UIViewController {
let viewModel = LoginViewModel()
private var bag = Set<AnyCancellable>()
var statusLabel: UILabel = UILabel()
var usernameField: UITextField = UITextField()
override func viewDidLoad() {
super.viewDidLoad()
setupBindings()
}
private func setupBindings() {
// Observe status changes
viewModel.$state.status
.sink { [weak self] status in
self?.statusLabel.text = status
}
.store(in: &bag)
// Observe username changes with diff
viewModel.$state
.bindDiffed(to: \LoginState.username) { [weak self] model, usernameDiff in
model.username = usernameDiff.new
self?.usernameField.text = usernameDiff.new
print("Username changed from '\(usernameDiff.old ?? "")' to '\(usernameDiff.new)'")
}
.store(in: &bag)
// Safe array access for validation messages
let validationMessages = ["Username required", "Password too short", "Invalid email"]
@UIState var messages = validationMessages
let messageBinding = $messages.safe(0, default: "No message")
messageBinding.wrappedValue = "Ready to validate"
}
@objc func usernameChanged(_ sender: UITextField) {
viewModel.state.username = sender.text ?? ""
}
@objc func passwordChanged(_ sender: UITextField) {
viewModel.state.password = sender.text ?? ""
}
}π¬ SwiftUI Integration with `ObservableObject`
Use @ManagedState inside an ObservableObject ViewModel. Bridge objectWillChange so SwiftUI re-renders on state changes:
struct AppState: BehavioralStateContract {
var title: String = "Initial"
mutating func applyRules() {}
@SubscriptionBuilder
static func applyBindingRules(to state: RulesPublisher) -> [AnyCancellable] {
[]
}
@AnyTokenBuilder<Any>
static func applyAnyRules(to state: UIBinding<AppState>) -> [Any] {
[]
}
}
final class ViewModel: ObservableObject {
@ManagedState
var state: AppState = .init()
private var cancellable: AnyCancellable?
init() {
cancellable = $state.publisher
.dropFirst()
.sink { [weak self] _ in
self?.objectWillChange.send()
}
}
}Usage in SwiftUI
struct ContentView: View {
@ObservedObject var viewModel = ViewModel()
var body: some View {
Text(viewModel.state.title)
Button("Update") {
viewModel.state.title = "Updated"
}
}
}Observe changes via Combine
let viewModel = ViewModel()
viewModel.$state.publisher
.sink { state in
print("Title: \(state.title)")
}
.store(in: &cancellables)
viewModel.state.title = "Updated" // triggers sink + objectWillChangeπ± Platform Support
CombineExt supports all major Apple platforms:
- iOS 16+ - Full support for UIKit reactive patterns
- macOS 14+ - AppKit and SwiftUI compatibility
- tvOS 16+ - tvOS interface development
- watchOS 9+ - Watch app state management
- visionOS 1+ - Vision Pro app development
- macCatalyst 16+ - iPad apps on Mac
π§ Advanced Configuration
ManagedState Locking
// Thread-safe (default)
@ManagedState var state = MyState()
// No locking for single-threaded contexts
@ManagedState(lock: .absent) var state = MyState()
// Custom lock
@ManagedState(lock: .custom(NSRecursiveLock())) var state = MyState()Thread Safety and Race Conditions
β οΈ Important: While individual property access is thread-safe, compound operations like state.count += 1 are NOT atomic and may cause race conditions in concurrent code.
// β NOT thread-safe in concurrent code:
state.count += 1
// β
Thread-safe alternatives:
state.count = state.count + 1 // Direct assignment
state.withLock { value in // Atomic operation
value.count += 1
}Atomic Operations with withLock
Use withLock for atomic read-modify-write operations:
struct MyState: BehavioralStateContract {
mutating func applyRules() {}
@SubscriptionBuilder
static func applyBindingRules(to state: RulesPublisher) -> [AnyCancellable] { [] }
@AnyTokenBuilder<Any>
static func applyAnyRules(to state: UIBinding<Self>) -> [Any] { [] }
}
@ManagedState var state1 = MyState()
@ManagedState(lock: .absent) var state2 = MyState()
@ManagedState(lock: .custom(NSRecursiveLock())) var state3 = MyState()
// Atomic operations with lock
$state1.withLock { value in
let _ = value
}
// No locking for single-threaded contexts
$state2.withLock { value in
let _ = value
}
// Custom lock
$state3.withLock { value in
let _ = value
}Cyclic Dependency Detection
// Disable warnings in tests
ManagedStateCyclicDependencyWarning = false
ManagedStateCyclicDependencyMaxDepth = 50π§ͺ Testing & Quality Assurance
CombineExt includes comprehensive test coverage with 216+ tests ensuring reliability and stability:
Test Coverage
- Property Wrappers:
@ManagedState,@UIState,@UIBinding,@ValueSubject,@IgnoredState - Dynamic Callable: All property wrappers with dynamic callable support
- Publisher Extensions:
filterNils(),mapVoid(),CombineLatest5/6,Zip5/6 - Safe Operations: Collection access, array operations, bounds checking
- Thread Safety: Concurrent access patterns, race condition prevention
- Edge Cases: Memory management, performance, error scenarios
- Result Builders:
SubscriptionBuilder,AnyTokenBuilderfunctionality - Utility Functions:
differs()with comprehensive test coverage - Protocol Conformance:
CustomStringConvertible,CustomDebugStringConvertible,CustomLocalizedStringResourceConvertible - DiffedValue:
map(keyPath:),hasChanged(keyPath:)methods with full testing
Running Tests
swift testAll tests pass with 100% success rate, ensuring the library is production-ready.
β Summary
CombineExt simplifies building reactive UIKit applications with a comprehensive set of tools:
- State Management:
@ManagedState,@UIState,@ValueSubjectfor different reactive patterns - Data Tracking:
DiffedValuefor state transitions,@IgnoredStatefor transient data - Thread Safety:
withLockfor atomic operations, preventing race conditions in concurrent code - Safe Operations: Safe collection access, nil filtering, and crash prevention
- Advanced Binding: Rich binding DSL with both
WritableKeyPathandKeyPathsupport - Extended Publishers:
CombineLatest5/6,Zip5/6for complex data flows - Dynamic Callable: Intuitive property wrapper syntax for all reactive types
- Utility Functions:
differs()for efficient value comparison with key paths - Platform Support: iOS 16+, macOS 14+, tvOS 16+, watchOS 9+, visionOS 1+
- Clean Architecture: Perfect for MVVM/MVI patterns with type-safe, testable code
- SwiftUI Compatibility: Works seamlessly with SwiftUI while maintaining UIKit flexibility
- Comprehensive Testing: 216+ tests ensuring reliability and stability
- Full Documentation: Complete API documentation with examples and thread safety warnings
- Protocol Conformance: Full support for
CustomStringConvertible,CustomDebugStringConvertible, andCustomLocalizedStringResourceConvertible
π¬ Contributing
Issues, suggestions, and pull requests are welcome!
π License
MIT. See the LICENSE file for details.
Package Metadata
Repository: niksativa/combineext
Default branch: main
README: README.md