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.
Hands-on courses, live workshops, and personalized 1-on-1 coaching.
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.
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.
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:
InsuranceStore
manages insurance rates, insurance plans etc.CatalogStore
handles categories, filters, and sorting options.InventoryStore
tracks stock levels and availability.UserStore
manages user profiles.FulfillmentStore
oversees shipping, delivery, and returns.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.
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.
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.
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.
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)
}
}
}
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.