Years of building apps have taught me to create reusable components to prototype and build faster. One thing that keeps coming back: handling state when fetching asynchronous data.
Three screens you see all the time.
After some experimentation, I landed on this data structure:
@MainActor
@Observable
final class DataState<T> {
enum State {
case loading
case loaded(T)
case error(Error)
}
var state: State
init(_ state: State) {
self.state = state
}
}This DataState can be used like this:
@MainActor
@Observable
final class ViewModel {
var user: DataState<User> = DataState(.loading)
func loadUser() async {
user.state = .loading
do {
try await Task.sleep(for: .seconds(1))
user.state = .loaded(User())
} catch {
user.state = .error(error)
}
}
}And displayed like this:
struct ContentView: View {
@State private var viewModel = ViewModel()
var body: some View {
switch viewModel.user {
case .loading:
ProgressView()
.task {
await viewModel.loadUser()
}
case let .loaded(user):
Text(user.name)
case let .error(error):
Text(error.localizedDescription)
}
}
}Okay, we have something reusable, but we've clearly lost some readability and simplicity. That's because we introduced a new data structure without moving the business logic into it.
To fix this, let's first create a new view to encapsulate the switch logic.
struct DataStateView<T, Content: View>: View {
private let dataState: DataState<T>
private let makeContent: (T) -> Content
init(
_ dataState: DataState<T>,
@ViewBuilder content: (_ data: T) -> Content
) {
self.dataState = dataState
self.makeContent = content
}
var body: some View {
switch dataState.state {
case .loading:
ProgressView()
case let .loaded(data):
makeContent(data)
case let .error(error):
Text(error.localizedDescription)
}
}
}Now we can simplify our main view by using DataStateView instead of the switch:
struct ContentView: View {
@State private var viewModel = ViewModel()
var body: some View {
DataStateView(viewModel.user) { user in
Text(user.name)
}
.task {
await viewModel.loadUser()
}
}
}Much better! Now, let's make the initialization of a DataState more convenient.
extension DataState {
static var loading: DataState {
.init(state: .loading)
}
static func loaded(_ data: T) -> DataState {
.init(state: .loaded(data))
}
static func error(_ error: Error) -> DataState {
.init(state: .error(error))
}
}Now we can initialize our DataState like this:
// Before
var user: DataState<User> = DataState(.loading)
// After
var user: DataState<User> = .loadingMuch better! But there's still a repeating pattern in our view model:
user.state = .loading
do {
try await Task.sleep(for: .seconds(1))
user.state = .loaded(User())
} catch {
user.state = .error(error)
}When fetching async data, we always go through the same sequence:
- Set to loading.
- Run async code to fetch data.
- If there's no error, set the data. Otherwise, set to error.
Let's move this sequence into our DataState!
extension DataState {
func load(_ loadData: @MainActor () async throws -> T) async {
state = .loading
do {
let data: T = try await loadData()
state = .loaded(data)
} catch {
state = .error(error)
}
}
}Which simplifies our code even more! Here's the final result:
@MainActor
@Observable
final class ViewModel {
let user: DataState<User> = .loading
func loadUser() async {
await user.load {
try await Task.sleep(for: .seconds(1))
return User()
}
}
}
struct ContentView: View {
@State private var viewModel = ViewModel()
var body: some View {
DataStateView(viewModel.user) { user in
Text(user.name)
}
.task {
await viewModel.loadUser()
}
}
}Much simpler, more readable, and reusable!
Going further
There's always room to improve. It's definitely possible (and even recommended) to make the error and loading states of DataStateView customizable. You could imagine a syntax like this:
DataStateView(user) { user in
Text(user.name)
} loading: {
ProgressView()
} error: { error in
Text(error.localizedDescription)
}You could imagine going even further by creating a property wrapper to encapsulate our DataState:
@MainActor
@Observable
@propertyWrapper
final class AsyncData<T> {
var wrappedValue: T? {
get {
guard case let .loaded(data) = dataState.state else {
return nil
}
return data
}
set {
dataState.state = .loaded(newValue)
}
}
var projectedValue: DataState<T?> {
dataState
}
private let dataState: DataState<T?>
init(_ wrappedValue: T? = nil) {
if let data = wrappedValue {
self.dataState = .loaded(data)
} else {
self.dataState = .loading
}
}
}This would enable an even more elegant syntax:
@MainActor
@Observable
final class ViewModel {
@AsyncData var user: User?
func loadUser() async {
await $user.load {
try await Task.sleep(for: .seconds(1))
return User()
}
}
}Unfortunately, this approach doesn't work with @Observable. The @Observable macro doesn't support custom property wrappers, as it needs direct access to properties to handle observation of changes.
How do you handle async loading logic in your apps? Share your ideas or feedback with me on Mastodon or Bluesky!