Introducing Swift Streamable Actors: Reactive State for Swift Actors
I’m thrilled to announce the official release of Swift Streamable Actors, a new Swift macro library that brings AsyncStream-based reactivity to your Swift Actors. If you’ve ever wished you could observe changes to an actor’s properties as easily as an @Observable object, this library is for you.
The Inspiration: Manual Streamable Properties
Before diving into the macro, let’s look at the problem it solves. Swift Actors are fantastic for managing mutable state safely in concurrent environments. However, sharing that state reactively with the rest of your application can involve a fair bit of boilerplate.
My journey to this macro started with a common pattern I found myself writing whenever I needed to stream an actor’s property:
Swift
actor MyActor {
private var countObservers: [UUID: AsyncStream<Int>.Continuation] = [:]
var count: Int = 0 {
didSet {
countObservers.values.forEach { $0.yield(count) }
}
}
static func countStream(for actor: MyActor) async -> AsyncStream<Int> {
let (stream, continuation) = AsyncStream.makeStream(of: Int.self)
let id = UUID()
await actor.register(id: id, continuation: continuation)
continuation.onTermination = { _ in
Task { await actor.unregister(id: id) }
}
return stream
}
private func register(id: UUID, continuation: AsyncStream<Int>.Continuation) {
countObservers[id] = continuation
continuation.yield(count) // Initial value
}
private func unregister(id: UUID) {
countObservers.removeValue(forKey: id)
}
func increment() {
count += 1
}
}
This works! It’s robust, concurrent-safe, and provides a beautiful AsyncStream interface. But imagine doing this for five, ten, or even more properties in a complex actor. The boilerplate quickly becomes overwhelming, obscuring the actual business logic.
Enter the @Streamable Macro!
This is precisely the kind of repetitive, predictable code generation that Swift macros excel at. With swift-streamable-actors, you can achieve the same reactive capabilities with a single attribute:
Swift
import StreamableActors
@Streamable
actor SensorManager {
var reading: Double = 0.0
var status: String = "Calibrating"
// Use @StreamableIgnored for properties you don't want to stream
@StreamableIgnored
var internalID: UUID = UUID()
func update(to value: Double) {
reading = value
status = "Active"
}
}
And observing changes is just as clean:
Swift
let sensors = SensorManager()
Task {
// Access the stream via the Actor type, passing the instance
let readings = await SensorManager.readingStream(for: sensors)
for await value in readings {
print("New sensor reading: \(value)")
}
}
🧠 The Design Philosophy: Shadow Storage & Robustness
You might wonder how this magic happens. Instead of relying on didSet (which can sometimes lead to re-entrancy issues with complex observers), swift-streamable-actors uses a Shadow Storage pattern, similar to how @Observable works:
- Storage Redirection: Your original property (e.g.,
var reading: Double) is effectively renamed to a private backing field (e.g.,_reading). - Accessor Injection: The macro transforms the original property into a computed property. The
setaccessor updates the backing field and then iterates through a private registry ofAsyncStream.Continuationobjects toyieldthe new value. - Static Stream Factory: A static method is generated that handles creating the
AsyncStream, generating a uniqueUUIDfor the subscriber, and safely registering it within the actor’s isolated context. - Automatic Cleanup: Crucially, each stream is configured with an
onTerminationhandler. When a consumer stops listening, it triggers a backgroundTaskto call back into the actor and remove the specific observer from the registry, preventing memory leaks.
🚀 Get Started Today!
You can add swift-streamable-actors to your project by adding it as a Swift Package Manager dependency:
Swift
dependencies: [
.package(url: "https://github.com/malhal/swift-streamable-actors", from: "1.0.1")
]
Be sure to check out the GitHub repository for full documentation, examples, and to contribute! I’m excited to see how this simplifies your concurrent Swift applications. Feel free to reach out with feedback or questions!