Canonical implementation of the safe async state pattern in SwiftUI
This project demonstrates how to design safe async flows in SwiftUI when multiple async operations can affect the same state simultaneously.
This repository is the practical implementation of the article: "Designing Safe Async Flows in SwiftUI"
In real applications, a screen can be affected by multiple async flows:
// β Anti-pattern: Race conditions guaranteed
struct BadView: View {
@State private var items: [Item] = []
var body: some View {
List(items) { ... }
.task {
// Flow 1: Initial load
items = await fetchItems()
}
.refreshable {
// Flow 2: Manual refresh
items = await fetchItems()
}
.onAppear {
// Flow 3: Background sync
Timer.scheduledTimer(...) {
items = await fetchItems()
}
}
}
}Problems:
- What happens if the user refreshes while initial load is in progress?
- What happens if background sync finishes before refresh?
- Who decides which data is "correct"?
// β
Pattern: Centralized state with single owner
@MainActor
final class FeedStore: ObservableObject {
@Published private(set) var state = FeedState()
func loadInitial() async {
beginLoading()
let result = await api.fetch()
apply(result) // β Single entry point to state
}
func refresh() async {
beginRefreshing()
let result = await api.fetch()
apply(result) // β Same entry point
}
}struct FeedState {
var characters: [Quote] = []
var isLoading: Bool = false
var isRefreshing: Bool = false
var lastSync: Date?
var error: Error?
}Principle: State is an immutable struct. Only the store can modify it.
@MainActor
final class FeedStore: ObservableObject {
@Published private(set) var state = FeedState()
// ^^^^^^^
// View can read, NOT write
}Principle: @Published private(set) guarantees only the store mutates state.
// All mutations go through here
private func apply(_ result: Result<[Quote], Error>) {
switch result {
case .success(let characters):
state.characters = characters
state.lastSync = Date()
case .failure(let error):
state.error = error
}
state.isLoading = false
state.isRefreshing = false
}Principle: Regardless of where data comes from (load, refresh, sync), mutation is always the same.
struct ContentView: View {
@StateObject private var store = FeedStore()
var body: some View {
List(store.state.characters) { ... }
.task { await store.loadInitial() }
.refreshable { await store.refresh() }
}
}Principle: View only expresses intent. It doesn't coordinate, mutate, or decide.
- Executes when view appears (
.task) - Shows
ProgressViewwhile loading - Updates
lastSyncon completion
- Executes with pull-to-refresh (
.refreshable) - Shows native iOS refresh indicator
- Doesn't interfere with other flows
- Can execute periodically (Timer)
- Doesn't show loading indicators
- Doesn't overwrite in-progress data
- Xcode 15.0+
- iOS 17.0+
- Swift 5.9+
git clone https://github.com/your-username/SwiftUIAsyncState.git
cd SwiftUIAsyncState
open SwiftUIAsyncState.xcodeproj- Select a simulator or device
- Press
Cmd + R - Test the different flows:
- Initial load happens automatically
- Pull down to refresh
- Observe the last sync timestamp
SwiftUIAsyncState/
βββ Models/
β βββ Quote.swift # Data model
β βββ FeedState.swift # Screen state
βββ API/
β βββ RickAndMortyAPI.swift # API client
βββ Store/
β βββ FeedStore.swift # β The pattern (state owner)
βββ Views/
βββ ContentView.swift # Logic-free view
The pattern is extremely testable because the store is independent of SwiftUI:
@Test
func testRefreshUpdatesState() async {
let store = FeedStore()
await store.refresh()
#expect(store.state.characters.isEmpty == false)
#expect(store.state.isRefreshing == false)
#expect(store.state.lastSync != nil)
}@Published private(set) var state = FeedState()
// ^^^^^^^^^^^
// Only the store can write// ALL updates go through apply()
private func apply(_ result: Result<[Quote], Error>) {
// Centralized mutation logic
}// β
GOOD: View delegates
.task { await store.loadInitial() }
// β BAD: View coordinates
.task {
items = await fetch()
if shouldRefresh {
items = await fetch()
}
}- Race conditions between multiple async operations
- Unpredictable UI state updates
- Hard to debug timing issues
- Difficult to test coordination logic
- β All state mutations go through a single point
- β Predictable state updates regardless of timing
- β Easy to test (store is pure Swift)
- β No possibility of race conditions
// Without pattern: Second request might overwrite first
// With pattern: Store decides which result to apply// Without pattern: Older data might overwrite newer data
// With pattern: Mutation order is explicit and controlled// Without pattern: Multiple states fight for control
// With pattern: Each mutation goes through same pipeline- π Full Article - Detailed explanation of the pattern
- π Swift Concurrency - Official docs
- π¬ WWDC: Discover concurrency in SwiftUI
Contributions are welcome! Please:
- Fork the project
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
This project is licensed under the MIT License.
Created as a practical demonstration of the article "Designing Safe Async Flows in SwiftUI"
Questions? Open an issue
If a screen can be affected by more than one async process, then:
- Order cannot be implicit
- State cannot be optimistic
- The view cannot be the coordinator
This pattern makes those rules explicit and enforceable.