Tous les articles
10 min·

.task vs .onAppear vs .refreshable en SwiftUI

Comprendre les différences entre .task, .onAppear et .refreshable en SwiftUI : cycle de vie, async, annulation automatique et pièges courants.

Par Carolane Lefebvre
iOSiPadOSmacOSSwiftSwiftUI
.task vs .onAppear vs .refreshable en SwiftUI

SwiftUI te donne trois modifiers pour exécuter du code au bon moment dans le cycle de vie d'une vue : .onAppear, .task et .refreshable. Trois closures, trois déclencheurs, trois façons de gérer (ou pas) l'annulation. Sur le papier, ils se ressemblent. En pratique, choisir le mauvais casse des choses de manière silencieuse.

Le piège classique, c'est .onAppear utilisé partout par réflexe. C'était la seule option jusqu'à iOS 15, donc on l'a tous fait. Mais aujourd'hui, continuer à encapsuler un Task { } dans .onAppear pour charger des données, c'est s'exposer à des fuites mémoire et des race conditions qui ne se manifestent que quand l'utilisateur navigue vite. Le genre de bug que tu ne vois jamais en développement mais qui apparaît en production.

Cet article détaille quand utiliser chacun, pourquoi, et surtout les pièges que personne ne mentionne dans la doc Apple. C’est parti !


.onAppear : L'original synchrone

.onAppear existe depuis iOS 13. C'est le premier modifier de cycle de vie que SwiftUI a proposé, et il se déclenche à chaque fois que la vue apparaît à l'écran. Pas juste la première fois : à chaque retour en arrière dans un NavigationStack, à chaque changement d'onglet dans un TabView, à chaque réapparition après un sheet. C'est important de le comprendre parce que ça veut dire que ta closure s'exécute potentiellement des dizaines de fois par session.

La closure est synchrone. Tu ne peux pas utiliser await directement dedans. C'est fait pour les effets de bord légers : logger un événement analytics, demander le focus sur un champ texte, basculer un flag booléen, mettre à jour un compteur de vues.

struct ProfileView: View {
    @State private var hasAppeared = false

    var body: some View {
        VStack {
            ProfileHeader()
            PostGrid()
        }
        .onAppear {
            AnalyticsService.log("profile_viewed")
            if !hasAppeared {
                hasAppeared = true
            }
        }
    }
}

Son pendant est .onDisappear, qui se déclenche quand la vue quitte l'écran. Ensemble, ils forment une paire pour gérer des effets symétriques comme démarrer/arrêter un timer.

Le vrai problème arrive quand tu essaies de faire de l'async dans .onAppear. C'est techniquement possible en créant un Task { } à l'intérieur, mais c'est une très mauvaise idée dans la majorité des cas. On va voir pourquoi juste après.

Le piège du Task { } dans .onAppear

C'est le pattern le plus courant et le plus dangereux en SwiftUI. Tu as besoin de charger des données au lancement d'un écran, donc tu fais :

.onAppear {
    Task {
        messages = try? await api.fetchMessages()
    }
}

Ça marche ! Jusqu'au moment où ça ne marche plus 🥲.

Le Task que tu crées dans .onAppear n'est lié à rien. SwiftUI ne le connaît pas. Quand l'utilisateur quitte l'écran (retour arrière, changement d'onglet, dismiss d'un sheet), la vue disparaît mais le Task continue de tourner. Il va terminer son appel réseau, essayer de mettre à jour messages, et potentiellement écrire dans une vue qui n'existe plus.

Imagine un écran de messagerie dans une app sociale. L'utilisateur ouvre une conversation, le fetch démarre, mais il change d'avis et revient en arrière en 200ms. Le Task continue, la réponse arrive, et tu as un write sur un @State orphelin. Multiplie ça par un utilisateur qui scroll vite entre les conversations et tu as une fuite mémoire progressive.

Il existe une solution bien sûr, tu peux gérer l'annulation manuellement avec .onDisappear :

@State private var loadTask: Task<Void, Never>?

.onAppear {
    loadTask = Task {
        messages = try? await api.fetchMessages()
    }
}
.onDisappear {
    loadTask?.cancel()
    loadTask = nil
}

Ça fonctionne, mais on peut mieux faire. Tu gères toi-même ce que .task fait automatiquement. Et tu risques d'oublier le .onDisappear sur un écran, ce qui suffit pour introduire un bug silencieux.

Et c’est là que notre vraie solution arrive : le .task

.task : Le choix par défaut pour l'async

.task est arrivé avec iOS 15 et c'est le modifier que tu devrais utiliser par défaut pour tout ce qui implique await. Il se déclenche à l'apparition de la vue, exactement comme .onAppear, mais avec deux différences majeures.

Premièrement, la closure est nativement async. Tu peux utiliser await directement, pas besoin d'encapsuler quoi que ce soit. Deuxièmement, et c'est la vraie raison d'exister de ce modifier : SwiftUI annule automatiquement la Task sous-jacente quand la vue disparaît. Plus de fuite, plus de write orphelin, plus de nettoyage manuel.

struct ConversationView: View {
    let conversationID: String
    @State private var messages: [Message] = []
    @State private var error: Error?

    var body: some View {
        MessageList(messages: messages)
            .task {
                do {
                    messages = try await api.fetchMessages(for: conversationID)
                } catch is CancellationError {
                    // La vue a disparu, rien a faire
                } catch {
                    self.error = error
                }
            }
    }
}

Quand l'utilisateur quitte cet écran, SwiftUI annule la task. Si ton appel réseau supporte la coopérative cancellation (et il devrait), il s'arrête immédiatement. Pas de réponse fantôme, pas de mémoire gaspillée.

.task est aussi parfait pour les observations longue durée. Tu peux observer un AsyncSequence ou écouter un flux WebSocket, et l'observation s'arrête proprement quand la vue disparaît :

.task {
    for await notification in NotificationCenter.default.notifications(named: .newMessage) {
        await refreshMessages()
    }
}

Disponible depuis iOS 15. Si tu supportes encore iOS 14 ou 13, tu es coincé avec le pattern .onAppear + Task manuel, mais c'est de plus en plus rare.

.task(id:) : Le re-fetch intelligent

.task(id:) est la variante de .task qui se relance à chaque fois que la valeur de id change. C'est le modifier qu'il te faut quand tes données dépendent d'un paramètre qui peut évoluer pendant la durée de vie de la vue.

Tu as un écran de catalogue avec un filtre par catégorie. L'utilisateur passe de "Électronique" à "Livres" à "Jeux vidéo". À chaque changement de filtre, tu dois recharger les produits. Sans .task(id:), tu devrais observer le changement manuellement avec onChange(of:) et lancer un nouveau Task à la main.

struct CatalogView: View {
    @State private var selectedCategory: Category = .electronics
    @State private var products: [Product] = []

    var body: some View {
        VStack {
            CategoryPicker(selection: $selectedCategory)
            ProductGrid(products: products)
        }
        .task(id: selectedCategory) {
            do {
                products = try await api.fetchProducts(category: selectedCategory)
            } catch is CancellationError {
                // Le filtre a change avant la fin du fetch precedent
            } catch {
                log(error)
            }
        }
    }
}

Le comportement est précis : quand selectedCategory change, SwiftUI annule la task en cours (celle du filtre précédent) et en lance une nouvelle avec la nouvelle valeur. Si l'utilisateur change trois fois de catégorie en une seconde, seule la dernière requête aboutit. Les deux premières sont annulées. Pas de race condition.

Autre cas d'usage courant : un écran de profil utilisateur dans une app de réseau social. L'utilisateur navigue entre les profils via des mentions ou des suggestions. Le userID change, et .task(id: userID) relance le chargement du profil automatiquement.

.task(id: userID) {
    profile = try? await api.fetchProfile(userID)
    posts = try? await api.fetchUserPosts(userID)
}

Un détail important : la valeur passée à id doit être Equatable. SwiftUI compare l'ancienne et la nouvelle valeur pour décider s'il faut relancer. Si tu passes un objet complexe, assure-toi que son Equatable est correct, sinon tu auras des relances fantômes ou des absences de relance.

.refreshable : Le pull déclenché par l'utilisateur

.refreshable ne se déclenche jamais automatiquement. Jamais à l'apparition, jamais quand les données changent. Uniquement quand l'utilisateur tire vers le bas sur une vue scrollable. C'est un geste explicite de sa part pour demander du contenu frais.

SwiftUI gère tout le visuel pour toi. Le spinner natif apparaît, reste visible pendant toute la durée de ta closure async, et disparaît quand elle retourne. Tu n'as pas besoin de gérer un @State private var isLoading ni d'afficher un ProgressView custom.

struct FeedView: View {
    @State private var posts: [Post] = []

    var body: some View {
        List(posts) { post in
            PostRow(post: post)
        }
        .refreshable {
            do {
                posts = try await api.fetchLatestPosts()
            } catch {
                // Optionnel : afficher une alerte d'erreur
            }
        }
    }
}

Un point important : la closure de .refreshable est async et bénéficie de l'annulation automatique, comme .task. Si l'utilisateur quitte l'écran pendant un refresh, la task est annulée.

.refreshable fonctionne sur List, ScrollView, Form, et même les sheets. Dans les cas où tu veux combiner un chargement initial et un pull-to-refresh, tu utilises .task pour le premier et .refreshable pour le second. Ils ne se marchent pas dessus.

Par contre, si ta closure retourne instantanément (par exemple parce que les données sont en cache), le spinner apparaît et disparaît en un flash, ce qui donne une impression de bug. Dans ce cas, ajoute un léger délai minimum pour que l'utilisateur voie que quelque chose s'est passé :

.refreshable {
    async let fetch = api.fetchLatestPosts()
    async let delay = Task.sleep(for: .milliseconds(500))
    posts = try await fetch
    _ = try? await delay
}

Les erreurs courantes

  • Utiliser .task pour du code synchrone. Si tu n'as pas besoin de await, utilise .onAppear. Le .task crée une task structurée, ce qui a un coût (minime mais réel). Pour un simple log analytics ou un toggle de booléen, .onAppear suffit.

    Oublier de gérer CancellationError. Quand SwiftUI annule une task (parce que la vue disparaît ou parce que lid change), ta closure reçoit une CancellationError. Si tu fais un catch générique qui affiche une alerte, l'utilisateur verra un message d'erreur alors qu'il n'y a pas de problème. Filtre toujours CancellationError.

  • Croire que .task ne se déclenche qu'une fois. .task se déclenche à chaque apparition de la vue, pas juste la première. Si ton écran apparaît à chaque changement d'onglet, ta closure s'exécute à chaque fois. Si tu veux un chargement unique, protège-le avec un flag.

  • Mettre .refreshable sur une vue qui n'est pas scrollable. Le geste de pull-to-refresh nécessite une vue qui scroll. Si tu mets .refreshable sur un VStack sans ScrollView, rien ne se passera et tu perdras du temps à chercher pourquoi.

Quand utiliser lequel

La décision tient en quatre règles :

  • Effet de bord synchrone à l'apparition (analytics, focus, toggle) : .onAppear.

  • Travail async à l'apparition (fetch, souscription, observation) : .task.

  • Re-fetch quand une valeur change (filtre, ID utilisateur, sélection) : .task(id:).

  • L'utilisateur tire vers le bas : .refreshable.

Et ils se composent ensemble. Un écran de données typique utilise les trois :

List(items) { item in
    ItemRow(item)
}
.onAppear {
    AnalyticsService.log("items_screen")
}
.task(id: selectedFilter) {
    do {
        items = try await api.fetchItems(filter: selectedFilter)
    } catch is CancellationError { }
    catch { self.error = error }
}
.refreshable {
    items = try? await api.fetchItems(filter: selectedFilter)
}

(c’est beau)

.task(id:) gère le chargement initial et les changements de filtre. .refreshable gère le pull. .onAppear gère le log. Chacun fait son job, sans se marcher dessus.


À retenir

Arrête de mettre .onAppear partout par défaut. Si tu fais de l'async, utilise .task. Si tu as un paramètre qui change, utilise .task(id:). Si tu veux du pull-to-refresh, utilise .refreshable. Réserve .onAppear aux effets synchrones légers. Le pattern Task { } dans .onAppear est un héritage d'iOS 14 qui n'a plus de raison d'exister dans un projet qui cible iOS 15+. Chaque modifier a son rôle, et le cycle de vie de ta vue te remerciera si tu respectes les frontières.

Commentaires

Connecte-toi pour laisser un commentaire.

Aucun commentaire pour le moment. Sois le premier !

Voir d'autres articles

iOS 26 a livré 5 améliorations discrètes en SwiftUI

safeAreaInset vs safeAreaBar en SwiftUI

5 modifiers SwiftUI pour maitriser vos Sheets