gh-Dimillian-Skills/swiftui-ui-patterns/references/lightweight-clients.md
Thomas Ricouard a66c570852 Add lightweight clients reference and index entry
Introduces a new reference file describing the lightweight, closure-based client pattern for SwiftUI apps. Updates the components index to include a link to this new guidance, supporting easier discovery and usage of small, testable API clients.
2026-01-07 21:37:45 +01:00

2.5 KiB
Raw Blame History

Lightweight Clients (Closure-Based)

Use this pattern to keep networking or service dependencies simple and testable without introducing a full view model or heavy DI framework. It works well for SwiftUI apps where you want a small, composable API surface that can be swapped in previews/tests.

Intent

  • Provide a tiny "client" type made of async closures.
  • Keep business logic in a store or feature layer, not the view.
  • Enable easy stubbing in previews/tests.

Minimal shape

struct SomeClient {
    var fetchItems: (_ limit: Int) async throws -> [Item]
    var search: (_ query: String, _ limit: Int) async throws -> [Item]
}

extension SomeClient {
    static func live(baseURL: URL = URL(string: "https://example.com")!) -> SomeClient {
        let session = URLSession.shared
        return SomeClient(
            fetchItems: { limit in
                // build URL, call session, decode
            },
            search: { query, limit in
                // build URL, call session, decode
            }
        )
    }
}

Usage pattern

@MainActor
@Observable final class ItemsStore {
    enum LoadState { case idle, loading, loaded, failed(String) }

    var items: [Item] = []
    var state: LoadState = .idle
    private let client: SomeClient

    init(client: SomeClient) {
        self.client = client
    }

    func load(limit: Int = 20) async {
        state = .loading
        do {
            items = try await client.fetchItems(limit)
            state = .loaded
        } catch {
            state = .failed(error.localizedDescription)
        }
    }
}
struct ContentView: View {
    @Environment(ItemsStore.self) private var store

    var body: some View {
        List(store.items) { item in
            Text(item.title)
        }
        .task { await store.load() }
    }
}
@main
struct MyApp: App {
    @State private var store = ItemsStore(client: .live())

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(store)
        }
    }
}

Guidance

  • Keep decoding and URL-building in the client; keep state changes in the store.
  • Make the store accept the client in init and keep it private.
  • Avoid global singletons; use .environment for store injection.
  • If you need multiple variants (mock/stub), add static func mock(...).

Pitfalls

  • Dont put UI state in the client; keep state in the store.
  • Dont capture self or view state in the client closures.