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.
This commit is contained in:
Thomas Ricouard 2026-01-07 21:37:45 +01:00
parent 1d42087b32
commit a66c570852
2 changed files with 94 additions and 0 deletions

View file

@ -26,6 +26,7 @@ Use this file to find component-specific guidance. Each entry lists when to use
- Deep links and URL routing: `references/deeplinks.md` — Use for in-app navigation from URLs.
- Title menus: `references/title-menus.md` — Use for filter or context menus in the navigation title.
- Loading & placeholders: `references/loading-placeholders.md` — Use for redacted skeletons, empty states, and loading UX.
- Lightweight clients: `references/lightweight-clients.md` — Use for small, closure-based API clients injected into stores.
## Planned components (create files as needed)

View file

@ -0,0 +1,93 @@
# 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
```swift
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
```swift
@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)
}
}
}
```
```swift
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() }
}
}
```
```swift
@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.