A lightweight, strongly-typed reactive programming library for Swift built on Swift Concurrency (AsyncStream, Task, Sendable) with zero external dependencies or runtime overhead.
Designed for modern Swift development, fully compliant with Swift 6 strict concurrency (-strict-concurrency=complete, Swift 5.9+ / Swift 6).
- Swift 6 strict concurrency by design. Not retrofitted with
@unchecked Sendablebandaids — every public type is built to compile clean under-strict-concurrency=completefrom the ground up. - Native Swift Concurrency, not a Combine wrapper. Built directly on
AsyncStream/Task/Sendable— no bridging layer, no dependency on Combine (unavailable on Linux, increasingly legacy on Apple platforms). - Explicit backpressure everywhere it matters.
merge,combineLatest,flatMap, andPipeall require an explicitBufferingPolicy. There is no silent, unbounded default that can grow memory under a slow consumer. - Cancellation without leaks or races.
TerminationStorageguarantees cleanup handlers still fire with the correct terminal reason even when cancellation happens mid-await(e.g. during a socket handshake) — a case most hand-rolled reactive wrappers get wrong. - Race-free nested task management.
TaskBoxatomically replaces and cancels inner tasks (debouncetimers,timeoutwatchdogs,flatMapLatestinner streams), so there's no window where a stale task can deliver after cancellation. - Deterministic, RAII-style lifecycle.
SubscriptionBagcancels everything automatically ondeinit; no manual bookkeeping, no dangling subscriptions. - Explicit cold/hot separation.
Flux<T>is cold by default;Pipe,CurrentValue/CurrentValueDistinct, andOnceare distinct, clearly-scoped hot primitives — no ambiguity like Combine'sPassthroughSubjectvsCurrentValueSubject. - Ergonomic push syntax. The
<-operator (events <- "value",events <- [a, b, c]) makes hot-source emission read naturally without sacrificing the explicit.send()API. - Zero external dependencies. Nothing to audit beyond the Swift standard library and Concurrency runtime — important for security-sensitive networking code.
- Protocol-boundary discipline built in. A documented invariant keeps raw transport frames out of concurrent fan-in operators, preserving strict ordering where it's required (e.g. decrypt → decode → validate → publish).
- Key Features
- Installation
- Architectural Concepts
- Quick Start
- Core Types
- State and Event Primitives
- Creating Streams
- Operator Reference
- Native Swift Async & Bridges
- Order Guarantees
- Protocol Boundary
- Practical Examples
- Building and Testing
- Changelog
- License
Add Flux to your Package.swift:
dependencies: [
.package(url: "https://github.com/resoul/flux.git", from: "1.0.0")
]Then add Flux to your target's dependencies:
.target(
name: "YourApp",
dependencies: [
.product(name: "Flux", package: "flux")
]
)Or add via Xcode: File → Add Package Dependencies... and enter https://github.com/resoul/flux.git.
Add the library to your BUILD file:
swift_library(
name = "MyTarget",
srcs = glob(["Sources/**/*.swift"]),
deps = [
"@flux//:Flux", # When imported as external repository
# or "//submodules/Flux:Flux" if used as a submodule
],
)- Cold by default:
Flux<T>is a cold reactive stream. Each subscription (.sink(),.sinkOnMain(), or accessing.stream) invokes the producer closure anew and creates an independentAsyncStream. - Complete Sendable Safety: All types are designed for Swift 6 Complete Concurrency — zero unprotected shared mutable state (synchronization via
NSLock/actor). - Deterministic Lifecycle: Subscriptions are managed explicitly via
SubscriptionandSubscriptionBag(RAII pattern: all tasks are cancelled automatically upon containerdeinit). - Leak and Race Prevention on Cancellation:
TerminationStorageensures cleanup handlers receive the terminal reason even if cancellation occurs during asynchronous resource initialization (e.g. while awaiting a socket connection).TaskBoxguarantees atomic replacement and cancellation of nested background tasks (watchdog intimeout, debounce delays, inner streams inflatMapLatest).
- Explicit Backpressure / Buffering Policy: Multi-source fan-in operators (
merge,combineLatest,flatMap) and hot sources (Pipe) require an explicitBufferingPolicyto protect memory from unbounded growth under slow downstream consumers.
import Flux
// 1. Create a stream pipeline
let searchFlux = Flux.from(["a", "ap", "app", "apple"])
.debounce(.milliseconds(300))
.flatMapLatest { query in
api.search(query) // returns Flux<[SearchResult]>
}
// 2. Subscribe on MainActor
let bag = SubscriptionBag()
searchFlux
.sinkOnMain { results in
tableView.reloadData(results)
}
.store(in: bag)Flux<T: Sendable> is the fundamental struct of the library, wrapping an () -> AsyncStream<T> factory.
- Callback-based subscription:
let sub = flux.sink { value in print("Received: \(value)") } // With completion handler: let subWithCompletion = flux.sink( next: { value in print(value) }, completed: { print("Stream completed") } )
- UI Subscription:
// Dispatched directly to @MainActor without redundant intermediate streams flux.sinkOnMain { [weak self] value in self?.label.stringValue = value }.store(in: bag)
Subscription: Thread-safe handle for an active subscription. Callingsub.cancel()immediately stops delivery and cancels associated background tasks.SubscriptionBag: Subscription container. Automatically cancels all stored subscriptions ondeinitorbag.cancelAll().
final class ChatViewController: NSViewController {
private let bag = SubscriptionBag()
override func viewDidLoad() {
super.viewDidLoad()
chatService.messages.flux
.sinkOnMain { [weak self] message in
self?.appendMessage(message)
}
.store(in: bag)
}
// When ChatViewController deinits, all subscriptions are automatically cancelled
}Use FluxEmitter<T> to construct custom Flux producers:
let networkStream = Flux<Data> { emitter in
let socket = openSocket()
// Register resource cleanup handlers
emitter.onCancellation {
socket.close() // called only when subscription is cancelled
}
emitter.onFinishOrCancel {
// called on both normal finish and cancellation
}
socket.onData { data in
emitter.send(data)
}
socket.onEnd {
emitter.finish()
}
}Note:
FluxEmitterusesTerminationStorage. If you register a cleanup handler after anawaitpoint (for instance, after awaiting a connection handshake) and the subscription was cancelled in the meantime, the cleanup callback is invoked immediately with the recorded termination reason.
A hot multicast broadcast source. Subscribers only receive values emitted after they subscribe.
let events = Pipe<String>(bufferingPolicy: .bufferingNewest(64))
// Subscribe
events.flux.sink { print("Event: \($0)") }.store(in: bag)
// Send via method:
events.send("Message 1")
// Send via operator <- :
events <- "Message 2"
events <- ["Batch 1", "Batch 2"]
// Buffer overflow telemetry:
let results = events.sendObservingOverflow("Critical Event")
if results.contains(where: { if case .dropped = $0 { return true }; return false }) {
// Handle buffer overflow caused by slow subscriber
}
// Complete the pipe
events.finish()State holder actor.
- New subscribers immediately receive the current value (replay = 1), followed by all subsequent state updates.
- Default buffering policy:
.bufferingNewest(1)(for a slow consumer, only the latest state matters).
enum ConnectionState: Sendable {
case disconnected, connecting, connected
}
let state = CurrentValue<ConnectionState>(.disconnected)
// Read current value:
let current = await state.value
// Update state:
await state.set(.connecting)
await state.modify { _ in .connected }
// UI subscription:
state.flux
.sinkOnMain { status in
statusLabel.stringValue = "\(status)"
}
.store(in: bag)CurrentValueDistinct<T: Equatable> behaves identically, but automatically deduplicates consecutive identical values (set(x) does not notify if the current value is already x).
An actor holding a single asynchronous result.
let authReady = Once<Bool>()
// In an async initialization task:
await authReady.resolve(true)
// Await directly:
let isReady = await authReady.wait()
// Or reactive subscription (emits 1 value and completes):
authReady.flux.sink { isReady in ... }| Method | Description |
|---|---|
Flux.just(value) |
Emits a single value and completes immediately. |
Flux.empty() |
Completes immediately without emitting any values. |
Flux.never() |
A stream that never emits values and never completes. |
Flux.from(sequence) |
Emits all elements from a collection/sequence in order, then completes. |
Flux.timer(duration) |
Waits for the specified Duration and completes without emitting values. |
Flux { emitter in ... } |
Custom generator closure with full control over emission and lifecycle callbacks. |
map { ... }— Synchronously transforms upstream values.asyncMap { await ... }— Asynchronously transforms values withawaitandTask.isCancelledchecks.compactMap { ... }— Transforms values and unwraps/filters outnilresults.flatMap(maxConcurrent:bufferingPolicy:) { ... }— Transforms each value into an innerFluxand merges results concurrently up tomaxConcurrentactive tasks.flatMapLatest { ... }— Transforms each value into an innerFlux, cancelling previous inner streams when a new outer value arrives.then(nextFlux)— Subscribes tonextFluxstrictly after the current stream completes.
filter { predicate }— Forwards only values matching the predicate.take(count)— Emits the firstcountvalues and completes.skip(count)— Drops the firstcountvalues.prefix(while:)— Emits values as long as the predicate holdstrue, then completes.drop(while:)— Drops values as long as the predicate holdstrue, then emits all remaining values.skipRepeats()— Drops consecutive duplicate values (requiresT: Equatable).
delay(duration)— Delays delivery of each value byduration.debounce(duration)— Waits for a quiet period ofdurationbefore delivering the latest value (ideal for text search fields).throttle(duration)— Limits emission rate to at most once perduration(leading throttle — delivers the first value in each window).timeout(duration)— Completes the stream if no values are received withinduration.
merge([flux1, flux2], bufferingPolicy:)— Merges values from multiple streams of the same type as they arrive.combineLatest(fa, fb, bufferingPolicy:)— Combines latest values from 2, 3, or 4 streams into a tuple whenever any input changes (starts emitting once each source has emitted at least once).zip(fa, fb)— Pairs values from two streams 1-to-1 (waits for(a1, b1), then(a2, b2)).
onMain()— Dispatches downstream value delivery toMainActor.sinkOnMain { ... }— Directly subscribes onMainActorwithout intermediate stream allocations (recommended for UI).onBackground(priority:)— Dispatches delivery inside a detached background task with the specified priority.handleEvents { ... }— Executes a side effect for each value without mutating the stream (logging/metrics).onCompletion { ... }— Executes a side effect upon stream completion.
Flux integrates seamlessly with standard Swift Concurrency:
// 1. Iterate using for await
for await value in flux.stream {
print(value)
}
// 2. Fetch first value
if let first = await flux.first() {
print("First value: \(first)")
}
// 3. Collect all values of a finite stream into an array
let allValues = await flux.collect()
// 4. Convert native AsyncStream into Flux
let fluxFromStream = nativeAsyncStream.asFlux()
// 5. Convert to throwing stream
let throwing = flux.throwingStream| Category | Operators | Order Guarantee |
|---|---|---|
| Sequential | map, filter, compactMap, take, skip, prefix, drop, then, skipRepeats |
Result strictly follows upstream order. |
| Time-filtering | debounce, throttle |
Relative order of delivered values is preserved (intermediate items may be dropped). |
| Concurrent (Fan-in / Multi-source) | merge, flatMap, combineLatest |
Order between sources is not guaranteed (depends on parallel task completion and Swift Concurrency runtime scheduling). |
| Switch-to-latest | flatMapLatest |
Previous inner task is guaranteed to be cancelled when a new outer value arrives. |
Important
Network Transport Invariant:
Flux is strictly intended for use after protocol decoding and validation — once raw network frames have been decrypted, validated, and converted into domain events (IncomingEvent).
The read loop remains strictly sequential:
Raw frames should not pass through concurrent fan-in operators (merge, flatMap, combineLatest) to preserve frame ordering and transport context.
final class SearchViewModel {
let queryPipe = Pipe<String>()
private let bag = SubscriptionBag()
@MainActor var searchResults: [SearchResult] = []
init(api: SearchAPI) {
queryPipe.flux
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { $0.count >= 2 }
.debounce(.milliseconds(300))
.skipRepeats()
.flatMapLatest { query in
api.searchFlux(query) // cancels previous in-flight network requests on new input
}
.sinkOnMain { [weak self] results in
self?.searchResults = results
}
.store(in: bag)
}
}let email = CurrentValue("")
let password = CurrentValue("")
let isFormValid = combineLatest(
email.flux,
password.flux,
bufferingPolicy: .bufferingNewest(1)
).map { email, pass in
email.contains("@") && pass.count >= 8
}
isFormValid
.sinkOnMain { isValid in
loginButton.isEnabled = isValid
}
.store(in: bag)client.connectionState?.sinkOnMain { [weak self] state in
switch state {
case .connected:
self?.statusIndicator.color = .systemGreen
case .reconnecting(let attempt):
self?.statusIndicator.color = .systemOrange
case .disconnected:
self?.statusIndicator.color = .systemRed
}
}.store(in: bag)# Swift Package Manager:
swift test
# Bazel:
bazel test //:FluxTestsSee CHANGELOG.md for detailed release history and changes.
Flux is released under the MIT License.