@Observable et le framework Observation en Swift : guide complet de migration depuis ObservableObject

Guide 2026 du framework Observation et de la macro @Observable en Swift : migration depuis ObservableObject, règles @State vs @Bindable, pièges concrets, withObservationTracking, type Observations (Swift 6.2) et SE-0506.

@Observable Swift 6.2 : Migration 2026

Bon, soyons honnêtes : si vous codez encore en 2026 avec ObservableObject et @Published partout, ce guide est pour vous. Depuis iOS 17 et Swift 5.9, Apple a complètement modernisé la gestion d'état dans SwiftUI grâce à la macro @Observable et au framework Observation. Et avec iOS 26 et Swift 6.2 cette année, c'est devenu de facto le standard pour toute nouvelle app — moins de boilerplate, suivi fin par propriété, et des gains de perf vraiment palpables (j'ai vu un de mes projets perso gagner près de 30 % de redraws en moins après migration).

Dans ce guide, on va couvrir la migration depuis ObservableObject, les pièges concrets rencontrés en prod (pas la théorie), les patterns avancés avec withObservationTracking, le tout nouveau type Observations qui s'intègre nativement avec async/await, et bien sûr les apports de SE-0506 (Advanced Observation Tracking).

Pourquoi @Observable remplace ObservableObject

L'ancienne approche, c'était Combine. À chaque modification d'une propriété @Published, on publiait objectWillChange… et toutes les vues observant l'objet étaient invalidées. Peu importe la propriété qui avait réellement changé. Sur une vue qui ne lit que username, un changement de isLoading provoquait quand même un redraw inutile. Frustrant, surtout sur les listes.

La macro @Observable, elle, change la donne. Elle injecte au moment de la compilation un ObservationRegistrar qui suit les accès propriété par propriété. Concrètement : SwiftUI sait précisément quelles propriétés chaque vue consulte, et n'invalide que celles qui sont réellement concernées.

Comparaison concrète

// Ancien — ObservableObject + Combine
final class UserViewModel: ObservableObject {
    @Published var username: String = ""
    @Published var isLoading: Bool = false
    @Published var errorMessage: String?
}

// Nouveau — @Observable
import Observation

@Observable
final class UserViewModel {
    var username: String = ""
    var isLoading: Bool = false
    var errorMessage: String?
}

Le code est plus court, certes. Mais surtout : si une vue n'affiche que username, un changement de isLoading ne déclenche plus aucun redraw. Sur une app avec plusieurs dizaines de vues partageant un même modèle, le gain est mesurable au profiler — et croyez-moi, c'est tout sauf anecdotique.

Tableau de migration : les correspondances exactes

Voici les substitutions à appliquer systématiquement quand vous migrez depuis ObservableObject. Gardez ce tableau sous la main, vous y reviendrez.

Ancien (Combine)Nouveau (Observation)
class Foo: ObservableObject@Observable class Foo
@Published var x = 0var x = 0
@StateObject@State
@ObservedObjectPropriété simple ou @Bindable
@EnvironmentObject@Environment(Foo.self)

Petit point important : @Published ne déclenche aucune erreur s'il reste sur une classe @Observable. Mais il ne sert plus à rien. Il faut absolument le retirer pour éviter les fausses pistes lors du débogage (j'ai déjà perdu une bonne heure là-dessus, donc je préviens).

Les règles d'or de la propriété d'un modèle

C'est ici que se cachent les bugs les plus pernicieux. Avec ObservableObject, @StateObject recevait un @autoclosure et n'initialisait l'instance qu'une seule fois sur tout le cycle de vie de la vue. @State n'a pas cette garantie : SwiftUI peut reconstruire la vue, et l'init du modèle sera appelé à chaque reconstruction.

SwiftUI conserve bien la première instance dans son storage interne, mais les instances suivantes peuvent rester en mémoire avant d'être déchargées de manière non déterministe par l'ARC. Sur des modèles lourds (chargement réseau, ouverture de fichiers, abonnements Combine), c'est un bug latent qui ne se révélera qu'au pire moment.

La règle simple

  • La vue qui crée l'instance du modèle @Observable utilise @State.
  • Les vues enfants reçoivent l'instance comme propriété simple si elles ne font que la lire.
  • Les vues enfants qui ont besoin de bindings ($model.property) utilisent @Bindable.
  • Ne jamais déclarer @State dans une vue enfant avec un modèle reçu : ça crée une copie locale décorrélée de la source. Erreur classique.
// Vue parent — propriétaire du modèle
struct ParentView: View {
    @State private var viewModel = UserViewModel()

    var body: some View {
        VStack {
            HeaderView(viewModel: viewModel)   // lecture seule
            FormView(viewModel: viewModel)     // a besoin de bindings
        }
    }
}

// Vue enfant en lecture seule
struct HeaderView: View {
    let viewModel: UserViewModel  // pas de wrapper

    var body: some View {
        Text(viewModel.username)
    }
}

// Vue enfant avec bindings
struct FormView: View {
    @Bindable var viewModel: UserViewModel

    var body: some View {
        TextField("Nom", text: $viewModel.username)
        Toggle("Chargement", isOn: $viewModel.isLoading)
    }
}

Comprendre @Bindable : le piège le plus fréquent

Sur une classe @Observable, l'opérateur $ n'existe pas par défaut. Si vous tentez d'écrire $viewModel.username sur une propriété simple, vous récolterez une jolie erreur de compilation. @Bindable, c'est précisément le wrapper qui synthétise les bindings sur les propriétés d'une classe Observable.

Cas spécial : @Environment + bindings

Quand un modèle est injecté via l'environnement et que vous avez besoin de bindings, le pattern correct (et un peu déroutant la première fois) consiste à redéclarer une variable locale @Bindable dans le corps de la vue :

struct SettingsView: View {
    @Environment(AppSettings.self) private var settings

    var body: some View {
        @Bindable var settings = settings  // shadow + bindings
        Toggle("Confirmer la suppression", isOn: $settings.confirmDeletion)
    }
}

Cette syntaxe étonne au premier abord — on déclare une variable locale qui masque celle de l'environnement, c'est contre-intuitif. Mais c'est le pattern officiel recommandé par Apple, et c'est léger puisque la classe est passée par référence.

Les 6 pièges classiques de la migration

1. Les objets imbriqués ne propagent pas automatiquement

Si une propriété calculée d'un modèle @Observable lit depuis un autre objet @Observable, la vue ne se met pas à jour quand la propriété imbriquée change — sauf si la vue lit directement la propriété imbriquée quelque part dans son body. C'est subtil, et c'est le piège n°1 selon mon expérience.

@Observable
final class Settings {
    var theme: Theme = .light
}

@Observable
final class AppState {
    let settings = Settings()
    var currentTheme: Theme { settings.theme }  // computed
}

// Cette vue NE sera PAS mise à jour quand settings.theme change
struct ThemeIndicator: View {
    let state: AppState
    var body: some View {
        Text(state.currentTheme == .dark ? "Sombre" : "Clair")
    }
}

Solution : exposez explicitement la propriété imbriquée, ou laissez la vue accéder à state.settings.theme directement.

2. Mélanger ObservableObject et @Observable dans la même hiérarchie

SwiftUI permet la cohabitation, mais ne convertit pas magiquement quoi que ce soit. Une vue qui reçoit un @ObservedObject ne profitera pas du suivi fin lorsque ce parent contient un objet @Observable imbriqué. Donc la migration partielle a ses limites.

3. Les boucles infinies de redraw

Si une vue enfant qui ne possède pas le modèle déclare quand même @State sur celui-ci, SwiftUI crée une nouvelle instance à chaque reconstruction. Selon la logique de votre init, vous pouvez déclencher des appels réseau dupliqués, des chargements en cascade, ou — pire — une boucle infinie de redraw qui gèle l'UI. Ça pique.

4. La sécurité de thread n'est pas automatique

@Observable ne rend pas votre classe thread-safe et n'ajoute aucune isolation d'acteur. Si plusieurs tâches modifient les propriétés en parallèle, c'est à vous d'isoler la classe avec @MainActor ou un acteur dédié.

@MainActor
@Observable
final class UserViewModel {
    var username: String = ""
    func reload() async { /* ... */ }
}

5. Conserver @Published par habitude

L'attribut @Published est silencieusement ignoré dans une classe @Observable. Aucun avertissement, aucune erreur — juste du code mort qui peut induire en erreur les futurs lecteurs (y compris vous-même dans six mois).

6. Initialisation lourde dans init()

Comme @State appelle l'initialiseur à chaque reconstruction de la vue, gardez le constructeur léger. Vraiment léger. Déplacez les chargements coûteux dans .task ou onAppear — c'est fait pour ça.

Patterns avancés avec withObservationTracking

SwiftUI utilise withObservationTracking en interne pour suivre les accès. Et vous pouvez l'utiliser directement dans du code non-SwiftUI : services, middleware, intégrations UIKit. Pratique quand on travaille sur du code legacy.

import Observation

func startObservation(on model: UserViewModel) {
    withObservationTracking {
        print("username =", model.username)
    } onChange: {
        // Le callback ne se déclenche QU'UNE SEULE FOIS
        Task { @MainActor in
            startObservation(on: model)  // ré-enregistrement récursif
        }
    }
}

Trois pièges critiques de withObservationTracking

  • Le callback ne se déclenche qu'une seule fois. Pour observer en continu, ré-enregistrez-vous depuis onChange. C'est le contrat de l'API.
  • onChange est appelé avant que la nouvelle valeur soit appliquée. Lire la propriété dans onChange renvoie l'ancienne valeur. Reportez la lecture dans une Task pour obtenir la nouvelle.
  • L'ordre n'est pas garanti. Avec plusieurs callbacks asynchrones, ne supposez aucun ordre d'exécution. Jamais.

Le type Observations : observation prête pour Swift Concurrency

Soyons clairs : le pattern récursif de withObservationTracking est verbeux et ne s'intègre pas naturellement avec async/await. Le type Observations, introduit avec Swift 6.2, est un AsyncSequence qui fournit un flux de valeurs observées avec regroupement transactionnel automatique : si plusieurs propriétés changent dans la même transaction, un seul élément est émis. Élégant.

let observations = Observations { model.username }

for await username in observations {
    print("Nouvelle valeur :", username)
}

Dans le cycle de vie d'une vue SwiftUI, vous l'attachez naturellement avec .task :

.task {
    for await theme in Observations({ settings.theme }) {
        await applyThemeChange(theme)
    }
}

Avantages par rapport à withObservationTracking : pas de récursion manuelle, intégration native avec la cancellation des Task, et coalescence automatique des changements multiples. À mon avis, c'est l'API que tout le monde devrait utiliser dès qu'on a accès à Swift 6.2.

SE-0506 : Advanced Observation Tracking en 2026

La proposition SE-0506 ajoute deux mécanismes pour les cas avancés (middleware, infrastructures de widgets, ponts vers des frameworks pré-concurrence) :

  • Une surcharge de withObservationTracking avec un paramètre options (de type ObservationTracking.Options) qui contrôle finement quelles mutations déclenchent le callback.
  • Une nouvelle fonction withContinuousObservationTracking qui supprime le besoin de ré-enregistrement manuel et fournit un comportement proche du type Observations mais utilisable depuis du code synchrone.

Attention : ces APIs ne remplacent pas la macro @Observable pour le code SwiftUI ordinaire. Elles ciblent les développeurs de frameworks et d'infrastructures d'observation.

Stratégie de migration progressive

Avec l'adoption iOS 17+ ultra-majoritaire en 2026, la barrière technique a disparu. Cela dit, ne refactorisez surtout pas toute l'app d'un coup — voici une approche éprouvée que j'utilise sur tous mes projets clients :

  1. Choisissez une feature isolée, idéalement nouvelle, comme premier terrain d'essai.
  2. Migrez un modèle à la fois, en commençant par les feuilles de l'arborescence (modèles peu partagés).
  3. Vérifiez les vues consommatrices : remplacez @StateObject par @State, @ObservedObject par @Bindable ou propriété simple, @EnvironmentObject par @Environment(MonType.self).
  4. Testez la propriété de la vue au memory graph debugger — vérifiez qu'aucune instance fantôme ne traîne dans la mémoire.
  5. Appliquez la « règle du scout » : à chaque touche d'une vue qui utilise encore l'ancien système, finalisez sa migration si le scope reste raisonnable.

SwiftUI accepte la cohabitation des deux systèmes pendant toute la durée de la migration. Un modèle ObservableObject peut coexister avec un modèle @Observable sans aucun problème. Donc pas de panique.

Performance : ce que mesurent vraiment les benchmarks

Les gains les plus mesurables apparaissent sur trois scénarios :

  • Listes longues partageant un modèle. Avec ObservableObject, modifier searchQuery redessine chaque cellule. Avec @Observable, seules les vues lisant searchQuery sont invalidées.
  • Modèles à nombreuses propriétés. Plus le modèle a de propriétés indépendantes, plus le suivi fin économise de redraws.
  • Hiérarchies profondes. Le suivi par accès évite la propagation d'invalidations à travers plusieurs niveaux de vues.

En revanche, sur un modèle simple consommé par une seule vue, l'écart est négligeable. Soyons honnêtes : la motivation de la migration, dans ce cas, c'est la lisibilité du code et l'alignement avec le standard Apple actuel. Pas la performance brute.

FAQ

Quelle est la version minimale d'iOS pour utiliser @Observable ?

iOS 17, iPadOS 17, macOS 14 (Sonoma), tvOS 17 et watchOS 10. Le framework Observation est disponible sur Linux et toute plateforme supportée par Swift 5.9+. Si vous devez encore supporter iOS 16 ou antérieur, gardez ObservableObject — le polyfill communautaire Perception peut servir de pont temporaire.

Faut-il remplacer @StateObject par @State ou par @Bindable ?

Par @State dans la vue qui crée l'instance. @Bindable sert uniquement aux vues qui reçoivent une instance et ont besoin de bindings ($). Pour une vue enfant en lecture seule, n'utilisez aucun wrapper — une simple propriété let ou var suffit largement.

@Observable est-il compatible avec SwiftData ?

Oui, totalement. Les classes @Model de SwiftData sont automatiquement observables — elles utilisent le même framework Observation en interne. Pas besoin d'ajouter @Observable manuellement à un @Model (et d'ailleurs, ça générerait une erreur).

Pourquoi mon objet @Observable imbriqué ne déclenche pas de mise à jour ?

Le suivi est lié aux propriétés que la vue lit directement dans son body. Si vous accédez à state.currentTheme via une propriété calculée qui lit en interne state.settings.theme, SwiftUI ne voit pas l'accès à theme. Solution : faites lire la propriété imbriquée explicitement par la vue, ou exposez-la directement via une propriété calculée déclarée dans la même classe (la macro la suivra alors correctement).

Comment observer un @Observable en dehors de SwiftUI ?

Trois options : withObservationTracking avec ré-enregistrement récursif pour du code synchrone simple ; le type Observations (Swift 6.2+) en boucle for await pour du code async/await ; ou withContinuousObservationTracking de SE-0506 quand vous avez besoin d'observation continue depuis du code synchrone.

La migration vers @Observable casse-t-elle Combine ?

Non. Vous perdez l'éditeur objectWillChange automatique et les @Published, mais rien ne vous empêche d'exposer manuellement un PassthroughSubject ou un CurrentValueSubject pour les besoins Combine spécifiques. Cela dit, la plupart des usages Combine peuvent désormais être remplacés par Observations + AsyncSequence, plus alignés avec Swift Concurrency.

Conclusion

Le couple @Observable + framework Observation représente le standard de gestion d'état Swift en 2026. Moins de boilerplate, suivi fin par propriété, intégration native avec Swift Concurrency via Observations, et compatibilité naturelle avec SwiftData. La migration depuis ObservableObject est conceptuellement simple, mais demande de bien comprendre les nouvelles règles de propriété (@State versus @Bindable versus propriété simple) pour éviter les fuites mémoire et les boucles de redraw.

Ma recommandation, après plusieurs migrations sur de gros projets : allez-y progressivement, modèle par modèle, en commençant par les feuilles de l'arborescence des vues. Et utilisez Observations dès que vous touchez à du code Swift Concurrency. Vous me remercierez plus tard.

À propos de l'auteur Tomasz Wojcik

Tomasz is a Krakow-based iOS engineer with 11 years of Swift experience. He spent four years at Revolut on the Wealth team, where he rewrote the trading charts in SwiftUI and shaved 40% off cold-start time by lazy-loading the analytics SDK. Before Revolut he was at Allegro, Poland's largest e-commerce platform, on the Seller Center iOS team. His specialty is iOS performance work: Instruments deep-dives, memory-graph debugging, and figuring out why your scroll view drops frames only on iPhone SE 2nd-gen. He has contributed patches to swift-syntax and writes a quarterly newsletter for iOS engineers that covers under-discussed APIs like BackgroundTasks and NSFileCoordinator. Tomasz holds the iOS App Development with Swift certification from Apple and occasionally runs paid workshops on Swift concurrency for in-house engineering teams in Europe.