Skip to content

ebarquin/SwiftUIAsyncState

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

5 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

SwiftUI Async State Pattern

Canonical implementation of the safe async state pattern in SwiftUI

Swift Platform License

This project demonstrates how to design safe async flows in SwiftUI when multiple async operations can affect the same state simultaneously.

πŸ“– Context

This repository is the practical implementation of the article: "Designing Safe Async Flows in SwiftUI"

The Problem It Solves

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"?

βœ… The Solution: A Single State Owner

// βœ… 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
    }
}

πŸ—οΈ Architecture

1. State

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.

2. Store (Single Owner)

@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.

3. Centralized Mutations

// 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.

4. "Boring" View

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.

🎯 Implemented Flows

1. Initial Load (loadInitial)

  • Executes when view appears (.task)
  • Shows ProgressView while loading
  • Updates lastSync on completion

2. Manual Refresh (refresh)

  • Executes with pull-to-refresh (.refreshable)
  • Shows native iOS refresh indicator
  • Doesn't interfere with other flows

3. Background Sync (syncInBackground)

  • Can execute periodically (Timer)
  • Doesn't show loading indicators
  • Doesn't overwrite in-progress data

πŸš€ How to Use This Project

Requirements

  • Xcode 15.0+
  • iOS 17.0+
  • Swift 5.9+

Installation

git clone https://github.com/your-username/SwiftUIAsyncState.git
cd SwiftUIAsyncState
open SwiftUIAsyncState.xcodeproj

Run

  1. Select a simulator or device
  2. Press Cmd + R
  3. Test the different flows:
    • Initial load happens automatically
    • Pull down to refresh
    • Observe the last sync timestamp

πŸ“š Project Structure

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

πŸ§ͺ Testing

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)
}

πŸ’‘ Key Principles

1. Single state owner

@Published private(set) var state = FeedState()
//              ^^^^^^^^^^^
//              Only the store can write

2. Explicit and centralized mutations

// ALL updates go through apply()
private func apply(_ result: Result<[Quote], Error>) {
    // Centralized mutation logic
}

3. View only expresses intent

// βœ… GOOD: View delegates
.task { await store.loadInitial() }

// ❌ BAD: View coordinates
.task { 
    items = await fetch()
    if shouldRefresh {
        items = await fetch()
    }
}

πŸ” Why This Pattern Matters

Without the pattern:

  • Race conditions between multiple async operations
  • Unpredictable UI state updates
  • Hard to debug timing issues
  • Difficult to test coordination logic

With the pattern:

  • βœ… 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

πŸ“ˆ Common Scenarios

Scenario 1: User refreshes during initial load

// Without pattern: Second request might overwrite first
// With pattern: Store decides which result to apply

Scenario 2: Background sync completes before manual refresh

// Without pattern: Older data might overwrite newer data
// With pattern: Mutation order is explicit and controlled

Scenario 3: Multiple rapid refreshes

// Without pattern: Multiple states fight for control
// With pattern: Each mutation goes through same pipeline

πŸŽ“ Learn More

🀝 Contributing

Contributions are welcome! Please:

  1. Fork the project
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

πŸ“ License

This project is licensed under the MIT License.

πŸ‘¨β€πŸ’» Author

Created as a practical demonstration of the article "Designing Safe Async Flows in SwiftUI"


Questions? Open an issue

🌟 Key Takeaway

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.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages