Question 8
Loading, empty, and error states across features
Every screen implements spinners and alerts differently, causing inconsistent UX and duplicate code. What architectural approach unifies this?
Follow-ups
- How does this relate to accessibility?
Answer outline
Use one shared state model, as a protocol, for async screens: idle, loading, loaded, empty, and failed.
This is better than separate booleans like isLoading, data, and errorMessage, because those can disagree with each other.
Each feature view model owns its own loading transition, but the app uses a shared renderer for spinners, empty states, errors, and retry buttons.
Principles
- Prefer one mutually exclusive state over several booleans.
- Keep loading state in the view model, not scattered through the view.
- Share the UI treatment for loading, empty, error, and retry states.
- Map technical errors into user-friendly messages in one place.
- Accessibility should follow the same state switch as the visible UI.
`LoadState` + shared rendering
enum LoadState<Value> {
case idle
case loading
case loaded(Value)
case empty
case failed(Error)
}
@MainActor
@Observable
final class SearchViewModel {
private(set) var loadState: LoadState<[SearchResult]> = .idle
private let api: SearchFetching
init(api: SearchFetching) { self.api = api }
func load() async {
loadState = .loading
do {
let results = try await api.fetchResults()
loadState = results.isEmpty ? .empty : .loaded(results)
} catch {
loadState = .failed(error)
}
}
}
struct LoadStateView<Value, Content: View>: View {
let state: LoadState<Value>
@ViewBuilder let content: (Value) -> Content
let onRetry: () -> Void
var body: some View {
switch state {
case .idle, .loading:
ProgressView()
case .loaded(let value):
content(value)
case .empty:
ContentUnavailableView("No results", systemImage: "magnifyingglass")
case .failed:
VStack {
ContentUnavailableView("Something went wrong", systemImage: "wifi.slash")
Button("Retry", action: onRetry)
}
}
}
}