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.
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> = .loadingBien 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 :
- On passe en mode loading.
- On exécute notre code asynchrone pour récupérer les données.
- 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 !