AzamSharp

Effective Communication Between Observable Stores in SwiftUI

Modern SwiftUI applications often rely on observable stores to manage state and business logic. As apps grow in complexity, these stores need to communicate efficiently—whether reacting to user actions, synchronizing data, or triggering side effects. This article explores practical patterns for inter-store communication, from direct method calls to event-driven approaches like Combine publishers and Swift Concurrency’s AsyncStream.

We’ll examine the trade-offs of each technique, including:

By aligning stores with bounded contexts (e.g., UserStore, InsuranceStore) and adopting the right communication strategy, you can keep your codebase modular, testable, and free from spaghetti dependencies. Whether you’re building a small app with a single store or a large-scale system with many interconnected domains, this guide provides actionable insights to streamline store interactions while keeping SwiftUI views lean and focused.

What is a Store?

A Store is a class that either conforms to the ObservableObject protocol or uses the @Observable macro. Unlike view models, you don’t create a separate store for each screen. Instead, stores are organized around the bounded contexts of your application. In other words, a store represents a logical slice of your app’s data and business rules, not just a single view.

For example, a ProductStore could power multiple views such as ProductListScreen, AddProductScreen, and ProductDetailScreen. The same store instance ensures consistency across these screens.

Stores primarily manage business logic, not presentation logic. Any presentation-specific behavior can live inside SwiftUI views themselves or, if complex, be extracted into dedicated value types (structs).

Stores can also have dependencies, such as a networking layer. They use these dependencies to fetch or persist data, while holding only the minimal state required by the view.

For instance, even if the server holds 100,000 records, your store might fetch only 50 at a time using pagination and expose that slice to the view.

Over the years, I’ve come across many different implementations of what I now call stores. Depending on the team or the codebase, they’ve been labeled as services, controllers, or even view models.

A Single Store

Depending on your app and its requirements, you may start with a single store that manages the entire application state. This approach works well for small to medium-sized apps. Personally, I prefer beginning with a single store and introducing additional stores only when the complexity of the app demands it.

Here’s a snippet from my PlatziStore implementation:

@MainActor
@Observable
class PlatziStore {
    
    let httpClient: HTTPClient
    var categories: [Category] = []
    var locations: [Location] = []
    
    init(httpClient: HTTPClient) {
        self.httpClient = httpClient
    }
    
    func loadCategories() async throws {
        let resource = Resource(url: Constants.Urls.categories, modelType: [Category].self)
        categories = try await httpClient.load(resource)
    }
    
    func createCategory(name: String) async throws {
       // code ...
    }
    
    func fetchProductsBy(_ categoryId: Int) async throws -> [Product] {
       // code ...
    }
    
    func createProduct(title: String, price: Double, description: String, categoryId: Int, images: [URL]) async throws -> Product {
        // code ...
    }
    
    func deleteProduct(_ productId: Int) async throws -> Bool {
       // code ...
    }
    
    func loadLocations() async throws {
       // code ...
    }
    
}

Stores can be injected either at the root of your application or at any specific node in the SwiftUI view hierarchy. Once injected, they become accessible through the @Environment property wrapper.

A good practice is to limit direct store access to container views. Child views should instead receive only the data they need via their initializers. This keeps child views lightweight, reusable, and free from unnecessary dependencies.

This also improves SwiftUI’s performance, since views are only re-evaluated and re-rendered when their dependencies actually change.

The following implementation demonstrates this approach:


    @Environment(PlatziStore.self) private var store 

    var body: some View {
        ProductListView(products: store.products)
        LocationsView(locations: store.locations)
    }

A single store may be sufficient for small to medium-sized applications, but as your app grows larger, it becomes important to split responsibilities across multiple stores.

Multiple Stores

I prefer to design and divide stores based on the bounded context of the application. A bounded context defines a clear boundary around a specific domain or responsibility, ensuring each store manages only the data and logic relevant to that area. For example, in an e-commerce app:

By aligning stores with bounded contexts rather than screens, the codebase remains modular, scalable, and easier to reason about.

A store does not operate in isolation—it often needs to communicate with other stores to retrieve or share information. This becomes especially important when an action in one store triggers a sequence of events that other stores must handle. In the next section, we’ll explore different strategies for propagating and handling domain events across stores.

Handling Store Events in the View

Consider a scenario in a healthcare or insurance app: whenever a user adds a new dependent, the insurance rates must be recalculated. In this setup, a UserStore is responsible for managing user information (including dependents), while an InsuranceStore handles insurance options, premiums, and rates for each user.

The question is: how should the insurance rates be updated when a new dependent is added?

One straightforward approach is to give the view access to both UserStore and InsuranceStore. After successfully adding a dependent through UserStore, the view can directly trigger a recalculation of insurance rates by calling the appropriate function on InsuranceStore.

The following implementation demonstrates this approach:

struct ContentView: View {
    
    @Environment(UserStore.self) private var userStore
    @Environment(InsuranceStore.self) private var insuranceStore
    @State private var insuranceRate: InsuranceRate?
    
    let userId = UUID()
    
    private func addDependent() async {
        let dependent = Dependent(id: UUID(), name: "Nancy", dob: Date())
        do {
            // add dependent to user management store
            try await userStore.addDependent(dependent, to: userId)
            insuranceRate = try await insuranceStore.calculateInsuranceRates(userId: userId)
        } catch {
            print(error.localizedDescription)
        }
    }
}

One major drawback of this approach is that any additional side effects must also be triggered directly from the view. For example, just as we manually called insuranceStore.calculateInsuranceRates after adding a dependent, we would need to add more calls in the view as new side effects are introduced. Over time, this makes the view more complex, tightly coupled to business logic, and harder to test.

Handling Store Events Through Delegate

Although I don’t typically use the delegate pattern in SwiftUI, it can still be applied if you want to forward an event to a single store. The first step is to define a delegate protocol, as shown in the implementation below:

@MainActor
protocol UserStoreDelegate: AnyObject {
    func dependentAdded(dependent: Dependent, userId: UUID) async
}

The UserStore is then updated to include a delegate property. At composition time, this property can be assigned to another store, which will act as the delegate and handle events emitted by UserStore.

@MainActor
@Observable
class UserStore {
    
    let httpClient: HTTPClient
    weak var delegate: (any UserStoreDelegate)?
    
    init(httpClient: HTTPClient) {
        self.httpClient = httpClient
    }
    
    // add a dependent to the user
    func addDependent(_ dependent: Dependent, to userId: UUID) async throws {
        
        guard var user = try await getUser(by: userId) else {
            throw UserError.userNotFound
        }
        
        user.dependents.append(dependent)
        await delegate?.dependentAdded(dependent: dependent, userId: userId)
    }
    
}

The Insurance becomes conforms to the delegate and implements the dependentAdded function.

@MainActor
@Observable
class InsuranceStore: UserStoreDelegate {
    
    let httpClient: HTTPClient
    var insuranceRate: InsuranceRate?
    
    init(httpClient: HTTPClient) {
        self.httpClient = httpClient
    }
    
    func dependentAdded(dependent: Dependent, userId: UUID) {
        // calculate rates
        calculateInsuranceRates(dependent: dependent, userId: userId)
        print("dependentAdded InsuranceStore")
    }
    
    private func calculateInsuranceRates(dependent: Dependent, userId: UUID) {
        insuranceRate = InsuranceRate(
            monthlyPremium: Decimal(Double.random(in: 300...600)),
            deductible: Decimal(Double.random(in: 500...2000)),
            coverageAmount: Decimal(Double.random(in: 10_000...50_000))
        )
    }
}

Make sure to hook up the delegate in the App file. This is shown below:

@main
struct LearnApp: App {
    
    @State private var userStore: UserStore
    @State private var insuranceStore: InsuranceStore
    
    init() {
        let http = HTTPClient()
        let userStore = UserStore(httpClient: http)
        let insurance = InsuranceStore(httpClient: http)
        userStore.delegate = insurance   // wire them here
        _userStore = State(initialValue: userStore)
        _insuranceStore = State(initialValue: insurance)
    }

Now, whenever the UserStore adds a new dependent, the delegate’s method—InsuranceStore.dependentAdded—is invoked automatically.

However, a key limitation of this approach is that the delegate pattern only supports a single listener. In other words, if another store—such as DocumentStore—also needs to react when a new dependent is added, this setup won’t work.

In the following sections, we’ll explore alternative techniques that make it possible to notify multiple listeners when such events occur.

Handling Store Events Using Combine Publishers

Stores can also broadcast events using the publisher–subscriber pattern. UserStore exposes a publisher that any interested consumer can subscribe to. In the example below, dependentAddedPublisher emits an event whenever a new dependent is successfully added, allowing multiple listeners to react without coupling the view to those side effects.

@MainActor
@Observable
class UserStore {
    
    var users: [User] = []
    
    private let dependentAddedSubject = PassthroughSubject<(Dependent, UUID), Never>()
    
    var dependentAddedPublisher: AnyPublisher<(Dependent, UUID), Never> {
        dependentAddedSubject.eraseToAnyPublisher()
    }
    
    func addDependent(_ dependent: Dependent, to userId: UUID) async throws {
        guard let index = users.firstIndex(where: { $0.id == userId }) else { return }
        users[index].dependents.append(dependent)
        
        dependentAddedSubject.send((dependent, userId))
    }

}

InsuranceStore subscribes to UserStore’s events in its initializer. During setup, it listens to dependentAddedPublisher and, whenever a new dependent is added, it asynchronously recalculates the user’s insurance rate. Errors from that recalculation are caught and stored (e.g., in lastError) without blocking the UI.

Because the store is annotated with @MainActor, updates to observable state like insuranceRate are safely performed on the main thread. The subscription closure captures self with [weak self], preventing retain cycles—so there’s no need to keep a separate (weak) property reference to UserStore. This wiring keeps views free of orchestration logic and allows multiple consumers to react to the same event stream independently. If you expect bursts of events, you can layer in operators like removeDuplicates, debounce, or throttle to control recalculation frequency.

@MainActor
@Observable
class InsuranceStore: Store {
    
    var insuranceRate: InsuranceRate?
    
    private var cancellables = Set<AnyCancellable>()
    private weak var userStore: UserStore?
    
    init(userStore: UserStore) {
        super.init()
        self.userStore = userStore
        self.userStore?.dependentAddedPublisher
            .sink { [weak self] dependent, userId in
                Task {
                    do {
                        try await self?.calculateInsurance(for: userId)
                        self?.lastError = nil
                    } catch {
                        self?.lastError = error
                    }
                }
            }.store(in: &cancellables)
    }
}

With this wiring, UserDetailScreen stays lean—it just adds the dependent. After that, UserStore emits a dependentAdded event, and every subscriber (e.g., InsuranceStore, DocumentStore) receives it and runs its own handler (recalculate rates, regenerate docs, etc.). No imperative calls from the view, no tight coupling, and side effects remain fully decoupled from UI code.

 Button("Add Dependent") {
                    Task {
                        let dependent = Dependent(id: UUID(), name: String.randomPersonName())
                        do {
                            try await userStore.addDependent(dependent, to: user.id)
                        } catch {
                            print(error.localizedDescription)
                        }
                    }
                }

By moving side effects out of the view and into subscribers, we keep UserDetailScreen simple and let each store own its domain logic. UserStore emits a single, typed event; InsuranceStore, DocumentStore, and any future consumers react independently—no tight coupling, no chains of imperative calls.

With this pattern, your UI stays predictable, tests get easier, and adding a new reaction (analytics, audit logs, notifications) is as simple as adding another subscriber—no changes to the view or the producer.

Handling Store Events Using AsyncStream

Swift Concurrency gives you a lightweight, Combine-free way to broadcast store events with AsyncStream. It fits naturally with async/await, keeps views thin, and scales to multiple listeners without wiring delegates.

We’ll begin by defining a typed UserEvent enum that represents all events the UserStore can emit. This gives us a clear, extensible, and type-safe way to model store events.

enum UserEvent {
        case dependentAdded(dependent: Dependent, userId: UUID)
        case dependentRemoved(dependent: Dependent, userId: UUID)
    }

Next, declare a continuations dictionary that holds one AsyncStream<UserEvent>.Continuation per active subscriber. A continuation is the write end of the stream—you use it to yield new events to listeners.

 // one continuation per subscriber
    private var continuations: [UUID: AsyncStream<UserEvent>.Continuation] = [:]

Next, when a consumer calls events(), we create and return a fresh AsyncStream and register its continuation. We also set continuation.onTermination to remove that entry when the subscriber stops listening—preventing leaks. The registration runs inside Task { @MainActor in … } because the AsyncStream builder isn’t main-actor–isolated, while the store’s state (the continuations dictionary) is.

Finally, the emit(_:) function fan-outs an event to every active subscriber. It iterates over the registered continuations and calls yield(event) on each, delivering the same event to all listeners.

private func emit(_ event: UserEvent) {
        for c in continuations.values { c.yield(event) }
    }

UserStore.addDependent is now wired to AsyncStream: after updating state, it emits a typed UserEvent on the stream, so all subscribers are notified immediately.

 func addDependent(_ dependent: Dependent, to userId: UUID) async throws {
        guard let index = users.firstIndex(where: { $0.id == userId }) else { return }
        users[index].dependents.append(dependent)
        emit(.dependentAdded(dependent: dependent, userId: userId))
    }

The startListening(_:) method in InsuranceStore subscribes to the AsyncStream<UserEvent> and reacts to events. The store keeps a cancellable Task so you can safely start/stop listening (and avoid multiple concurrent loops). When it receives .dependentAdded, it recalculates the user’s insurance rate.

@MainActor
@Observable
final class InsuranceStore {
    var insuranceRate: InsuranceRate?
    private var listener: Task<Void, Never>?

    func startListening(to events: AsyncStream<UserEvent>) {
        // Cancel any previous subscription
        listener?.cancel()

        listener = Task { [weak self] in
            guard let self else { return }
            for await event in events {
                switch event {
                case let .dependentAdded(userId, _):
                    do { try await self.calculateInsurance(for: userId) }
                    catch { /* log or emit error event */ }
                }
            }
        }
    }

    func calculateInsurance(for userId: UUID) async throws {
        // fetch & update insuranceRate
    }

    deinit { listener?.cancel() }
}

You can call startListening(to: userStore.events()) in your composition root (e.g., App.init()), not from a view. This keeps the view simple, ensures a single subscription per store, and makes teardown deterministic. The implementation is shown below:

@main
struct LearnApp: App {
    @State private var userStore: UserStore
    @State private var insuranceStore: InsuranceStore
    @State private var documentStore: DocumentStore

    init() {
        let user = UserStore()
        let insurance = InsuranceStore()
        let docs = DocumentStore()

        // Wire listeners once at startup
        insurance.startListening(to: user.events())
        docs.startListening(to: user.events())

        _userStore = State(initialValue: user)
        _insuranceStore = State(initialValue: insurance)
        _documentStore = State(initialValue: docs)
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(userStore)
                .environment(insuranceStore)
                .environment(documentStore)
        }
    }
}

Conclusion

At the end of the day, observable stores work best when each one owns a clear slice of your app’s world and keeps the business logic where it belongs—inside the store—so your SwiftUI views can stay light and focused. When stores need to react to each other, choose the simplest communication style that fits: calling directly from the view is quick but doesn’t age well; a delegate is great when there’s exactly one listener; and for multiple, independent reactions, reach for Combine or AsyncStream to broadcast events without coupling your UI to side effects.

Keep the wiring tidy by composing everything at the app root (e.g., App.init() or a small coordinator), not inside views. Let producers emit events without knowing who’s listening, annotate UI-facing stores with @MainActor, avoid retain cycles, cancel listeners on teardown, and handle errors locally (log them or emit short-lived error events instead of a global “last error”). Type your events as an enum so changes stay compiler-checked.

In practice, start simple, evolve when the architecture asks for it, and favor patterns that make adding new reactions—like analytics, audit logs, or notifications—as easy as adding one more subscriber. That way, your UI stays predictable, your tests stay focused, and your codebase stays flexible as your app grows.