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> = .loading
We can even go further by creating a property wrapper.
@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
}
}
}
Which lets us rewrite our view model like this:
@MainActor
@Observable
final class ViewModel {
@AsyncData var user: User?
func loadUser() async {
$user.state = .loading
do {
try await Task.sleep(for: .seconds(1))
user = User()
} catch {
$user.state = .error(error)
}
}
}
Nice, we've already shortened things a lot! But there's still a repeating pattern:
$user.state = .loading
do {
try await Task.sleep(for: .seconds(1))
user = 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 {
@AsyncData var user: User?
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
if let user {
Text(user.name)
} else {
Text("User not found")
}
}
.task {
await viewModel.loadUser()
}
}
}
Much simpler, more readable, and reusable! What do you think?
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)
}
This syntax is super handy, especially with a composition system. Imagine being able to add features to your view on the fly like this:
@MainActor
@Observable
final class ViewModel {
@AsyncData var users: [User]?
@SearchData(\ViewModel.users) var search
}
Maybe that'll be the topic of a future article if this solution turns out to be feasible and you're curious! How do you handle async loading logic in your apps? Share your ideas or feedback with me on Mastodon or Bluesky!