23 juin 2025

Chargement. Erreur. Tranquille.

Des années à créer des apps m'ont appris à créer des composants réutilisables pour prototyper et créer plus rapidement. Une chose qui revient sans cesse : la gestion d'état lors de la récupération de données asynchrones.

3 écrans avec un état loading, data et error Trois écrans qui reviennent systématiquement.

En expérimentant un peu, je suis arrivé à cette structure de donnée.

@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
    }
}

Ce DataState peut être utilisé comme ceci :

@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)
        }
    }
}

Et affiché de cette manière :

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)
        }
    }
}

Bon, on a bien sûr quelque chose de réutilisable, mais on a clairement perdu en lisibilité et simplicité. Ça vient du fait que nous avons introduit une nouvelle structure de données sans y transférer la logique métier.

Pour régler ce problème, créons tout d'abord une nouvelle vue qui nous permettra d'encapsuler la logique du switch.

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)
        }
    }
}

On peut maintenant alléger notre vue principale en utilisant notre DataStateView plutôt que notre 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()
        }
    }
}

Bien mieux ! Passons maintenant au view model. On peut commencer par rendre l'initialisation d'un DataState plus conviviale.

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))
    }
}

On peut maintenant initialiser notre DataState de cette manière :

// Avant
var user: DataState<User> = DataState(.loading)
// Après
var user: DataState<User> = .loading

Bien mieux ! Seulement, on voit encore un motif qui se répète dans notre view model :

user.state = .loading
do {
    try await Task.sleep(for: .seconds(1))
    user.state = .loaded(User())
} catch {
    user.state = .error(error)
}

Lorsqu'on récupère nos données asynchrones, on passe toujours par cette même séquence :

  1. On passe en mode loading.
  2. On exécute notre code asynchrone pour récupérer les données.
  3. S'il n'y a pas d'erreur, on set nos données. Sinon, on passe en mode erreur.

Déplaçons donc cette séquence dans notre 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)
        }
    }
}

Ce qui simplifie encore notre exécution ! Voici donc le résultat final :

@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()
        }
    }
}

Bien plus simple, lisible et réutilisable !

Aller plus loin

Il est toujours possible d'améliorer encore notre solution. Il est tout à fait envisageable (et même conseillé) de rendre le statut d'erreur et de chargement de la DataStateView personnalisable. On pourrait tout à fait imaginer cette syntaxe :

DataStateView(user) { user in
    Text(user.name)
} loading: {
    ProgressView()
} error: { error in
    Text(error.localizedDescription)
}

On pourrait imaginer aller encore plus loin en créant un property wrapper pour encapsuler notre 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
        }
    }
}

Ce qui permettrait une syntaxe encore plus élégante :

@MainActor
@Observable
final class ViewModel {
    @AsyncData var user: User?
 
    func loadUser() async {
        await $user.load {
            try await Task.sleep(for: .seconds(1))
            return User()
        }
    }
}

Malheureusement, cette approche ne fonctionne pas avec @Observable. La macro @Observable ne supporte pas les property wrappers personnalisés, car il a besoin d'accéder directement aux propriétés pour gérer l'observation des changements.

Et vous, comment gérez-vous la logique de chargement asynchrone dans vos apps ? Partagez-moi vos idées ou retours sur Mastodon ou Bluesky !

looping arrowsuivez-moi ici !