SwiftUI @Observable vs @State : quand utiliser quoi en 2026

Comprendre les différences entre @Observable et @State en SwiftUI pour choisir le bon outil de gestion d'état selon le contexte de votre application iOS.

Comparaison @Observable vs @State en SwiftUI pour développeurs iOS

La gestion d'état constitue le pilier central de toute application SwiftUI performante. Depuis iOS 17, le macro @Observable révolutionne la façon de créer des modèles réactifs, tandis que @State reste incontournable pour l'état local des vues. Comprendre quand utiliser chaque outil permet d'éviter les re-renders inutiles et de construire des applications fluides.

Ce que couvre cet article

Cet article explore les mécanismes internes de @Observable et @State, leurs différences fondamentales, et fournit des règles claires pour choisir le bon outil selon le contexte.

Les fondamentaux de @State

@State représente la forme la plus simple de gestion d'état en SwiftUI. Ce property wrapper crée un stockage persistant pour une valeur qui appartient exclusivement à la vue qui la déclare.

CounterView.swiftswift
struct CounterView: View {
    // @State crée un stockage géré par SwiftUI
    @State private var count = 0

    var body: some View {
        VStack(spacing: 20) {
            // La vue se met à jour quand count change
            Text("Compteur : \(count)")
                .font(.largeTitle)

            HStack(spacing: 16) {
                Button("- 1") {
                    count -= 1
                }

                Button("+ 1") {
                    count += 1
                }
            }
            .buttonStyle(.borderedProminent)
        }
    }
}

Chaque modification de count déclenche un re-render de la vue. SwiftUI gère automatiquement le cycle de vie de cette valeur, la préservant lors des reconstructions du body.

Caractéristiques clés de @State

@State possède plusieurs propriétés distinctives qui définissent son utilisation optimale :

StateCharacteristics.swiftswift
struct FormView: View {
    // ✅ État local simple - types valeur
    @State private var username = ""
    @State private var isEnabled = true
    @State private var selectedIndex = 0

    // ✅ Types valeur complexes supportés
    @State private var configuration = FormConfiguration()

    var body: some View {
        Form {
            TextField("Nom d'utilisateur", text: $username)

            Toggle("Activé", isOn: $isEnabled)

            Picker("Option", selection: $selectedIndex) {
                Text("Option A").tag(0)
                Text("Option B").tag(1)
                Text("Option C").tag(2)
            }
        }
    }
}

// Les structs fonctionnent parfaitement avec @State
struct FormConfiguration: Equatable {
    var theme: Theme = .light
    var fontSize: CGFloat = 16
    var showNotifications: Bool = true
}

enum Theme {
    case light, dark, system
}

L'élément crucial : @State fonctionne avec des types valeur (structs, enums, types primitifs). Pour les types référence (classes), d'autres outils sont nécessaires.

Le macro @Observable expliqué

Introduit avec iOS 17, @Observable transforme n'importe quelle classe en source de données réactive. Contrairement à l'ancien protocole ObservableObject, ce macro offre une granularité fine : seules les propriétés réellement lues par une vue déclenchent son re-render.

UserModel.swiftswift
import Observation

// @Observable transforme la classe en source réactive
@Observable
class UserModel {
    var name: String = ""
    var email: String = ""
    var avatarURL: URL?
    var preferences = UserPreferences()

    // Les propriétés calculées fonctionnent aussi
    var isValid: Bool {
        !name.isEmpty && email.contains("@")
    }
}

struct UserPreferences {
    var newsletter: Bool = false
    var notifications: Bool = true
    var theme: Theme = .system
}

La magie opère à la compilation : le macro génère automatiquement le code de tracking nécessaire pour chaque propriété.

Observation granulaire en action

La différence majeure avec l'ancien ObservableObject réside dans la granularité du tracking :

GranularObservation.swiftswift
@Observable
class ProfileModel {
    var name: String = ""
    var bio: String = ""
    var followerCount: Int = 0
    var posts: [Post] = []
}

struct ProfileHeaderView: View {
    let model: ProfileModel

    var body: some View {
        VStack {
            // Cette vue ne re-render que si name ou bio changent
            Text(model.name)
                .font(.title)
            Text(model.bio)
                .foregroundStyle(.secondary)
        }
    }
}

struct FollowerCountView: View {
    let model: ProfileModel

    var body: some View {
        // Cette vue ne re-render que si followerCount change
        HStack {
            Image(systemName: "person.2")
            Text("\(model.followerCount) abonnés")
        }
    }
}

struct ProfileScreen: View {
    @State private var model = ProfileModel()

    var body: some View {
        VStack {
            // Chaque sous-vue track uniquement ses dépendances
            ProfileHeaderView(model: model)
            FollowerCountView(model: model)

            Button("Simuler nouveau follower") {
                // Ne re-render que FollowerCountView
                model.followerCount += 1
            }
        }
    }
}
Tracking automatique

SwiftUI analyse le body de chaque vue pour déterminer quelles propriétés sont lues. Seules ces propriétés déclenchent un re-render lors de leur modification.

Comparaison directe @Observable vs @State

Le choix entre ces deux outils dépend de plusieurs facteurs. Voici une comparaison structurée :

ComparisonExample.swiftswift
// Scénario 1 : État UI temporaire → @State
struct ToggleExample: View {
    @State private var isExpanded = false  // ✅ @State approprié

    var body: some View {
        VStack {
            Button(isExpanded ? "Réduire" : "Développer") {
                withAnimation {
                    isExpanded.toggle()
                }
            }

            if isExpanded {
                Text("Contenu détaillé...")
            }
        }
    }
}

// Scénario 2 : Données métier partagées → @Observable
@Observable
class CartModel {  // ✅ @Observable approprié
    var items: [CartItem] = []
    var promoCode: String?

    var total: Decimal {
        items.reduce(0) { $0 + $1.price * Decimal($1.quantity) }
    }

    var itemCount: Int {
        items.reduce(0) { $0 + $1.quantity }
    }

    func addItem(_ item: CartItem) {
        if let index = items.firstIndex(where: { $0.id == item.id }) {
            items[index].quantity += 1
        } else {
            items.append(item)
        }
    }

    func removeItem(_ item: CartItem) {
        items.removeAll { $0.id == item.id }
    }
}

struct CartItem: Identifiable, Equatable {
    let id: UUID
    let name: String
    let price: Decimal
    var quantity: Int
}

Tableau récapitulatif des cas d'usage

| Critère | @State | @Observable | |---------|--------|-------------| | Type de données | Types valeur (struct, enum) | Classes | | Scope | Local à une vue | Partageable entre vues | | Complexité | État simple | Logique métier complexe | | Cycle de vie | Géré par SwiftUI | Géré explicitement | | Re-render | Vue entière | Granulaire par propriété |

Prêt à réussir tes entretiens iOS ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Patterns d'utilisation avancés

Combiner @State et @Observable

Dans les applications réelles, ces outils coexistent harmonieusement. @State gère l'état UI local tandis que @Observable encapsule les données métier.

CombinedPatterns.swiftswift
@Observable
class TodoListModel {
    var todos: [Todo] = []
    var filter: TodoFilter = .all

    var filteredTodos: [Todo] {
        switch filter {
        case .all:
            return todos
        case .active:
            return todos.filter { !$0.isCompleted }
        case .completed:
            return todos.filter { $0.isCompleted }
        }
    }

    func addTodo(title: String) {
        let todo = Todo(id: UUID(), title: title, isCompleted: false)
        todos.append(todo)
    }

    func toggleTodo(_ todo: Todo) {
        guard let index = todos.firstIndex(where: { $0.id == todo.id }) else { return }
        todos[index].isCompleted.toggle()
    }
}

struct Todo: Identifiable, Equatable {
    let id: UUID
    var title: String
    var isCompleted: Bool
}

enum TodoFilter: CaseIterable {
    case all, active, completed
}

struct TodoListView: View {
    // Données métier via @Observable
    @State private var model = TodoListModel()

    // État UI local via @State
    @State private var newTodoTitle = ""
    @State private var isAddingTodo = false
    @State private var selectedTodo: Todo?

    var body: some View {
        NavigationStack {
            VStack {
                // Filtre avec Picker
                Picker("Filtre", selection: $model.filter) {
                    ForEach(TodoFilter.allCases, id: \.self) { filter in
                        Text(filter.label).tag(filter)
                    }
                }
                .pickerStyle(.segmented)
                .padding()

                // Liste des todos
                List(model.filteredTodos, selection: $selectedTodo) { todo in
                    TodoRowView(todo: todo) {
                        model.toggleTodo(todo)
                    }
                }
            }
            .navigationTitle("Tâches")
            .toolbar {
                Button {
                    isAddingTodo = true
                } label: {
                    Image(systemName: "plus")
                }
            }
            .sheet(isPresented: $isAddingTodo) {
                AddTodoSheet(model: model)
            }
        }
    }
}

struct TodoRowView: View {
    let todo: Todo
    let onToggle: () -> Void

    var body: some View {
        HStack {
            Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
                .foregroundStyle(todo.isCompleted ? .green : .secondary)
                .onTapGesture(perform: onToggle)

            Text(todo.title)
                .strikethrough(todo.isCompleted)
        }
    }
}

extension TodoFilter {
    var label: String {
        switch self {
        case .all: return "Toutes"
        case .active: return "Actives"
        case .completed: return "Terminées"
        }
    }
}

@Observable avec injection de dépendances

Pour les applications plus complexes, l'injection via l'environnement SwiftUI permet un découplage efficace :

DependencyInjection.swiftswift
@Observable
class AuthenticationService {
    var currentUser: User?
    var isAuthenticated: Bool { currentUser != nil }

    func login(email: String, password: String) async throws {
        // Logique d'authentification
        currentUser = User(id: UUID(), email: email, name: "Utilisateur")
    }

    func logout() {
        currentUser = nil
    }
}

struct User: Identifiable, Equatable {
    let id: UUID
    let email: String
    let name: String
}

// Extension pour créer une clé d'environnement
extension EnvironmentValues {
    @Entry var authService: AuthenticationService = AuthenticationService()
}

// Configuration dans l'App
@main
struct MyApp: App {
    @State private var authService = AuthenticationService()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.authService, authService)
        }
    }
}

// Utilisation dans les vues
struct ProfileView: View {
    @Environment(\.authService) private var authService

    var body: some View {
        if let user = authService.currentUser {
            VStack {
                Text("Bonjour, \(user.name)")
                Button("Déconnexion") {
                    authService.logout()
                }
            }
        } else {
            Text("Non connecté")
        }
    }
}

Performance et optimisation

Éviter les re-renders inutiles

Même avec la granularité de @Observable, certains patterns peuvent dégrader les performances :

PerformanceOptimization.swiftswift
// ❌ Mauvais pattern : lecture de tout l'objet
struct BadPatternView: View {
    let model: ProfileModel

    var body: some View {
        // Lit model.name ET model.posts même si seul name est affiché
        let _ = model.posts.count  // Crée une dépendance inutile
        Text(model.name)
    }
}

// ✅ Bon pattern : lecture ciblée
struct GoodPatternView: View {
    let model: ProfileModel

    var body: some View {
        // Track uniquement name
        Text(model.name)
    }
}

// ✅ Extraction en sous-vues pour isoler les dépendances
struct OptimizedProfileView: View {
    let model: ProfileModel

    var body: some View {
        VStack {
            // Chaque sous-vue a ses propres dépendances
            ProfileNameView(model: model)
            ProfilePostsView(model: model)
            ProfileStatsView(model: model)
        }
    }
}

struct ProfileNameView: View {
    let model: ProfileModel

    var body: some View {
        Text(model.name)
            .font(.title)
    }
}

struct ProfilePostsView: View {
    let model: ProfileModel

    var body: some View {
        ForEach(model.posts) { post in
            PostRow(post: post)
        }
    }
}

struct ProfileStatsView: View {
    let model: ProfileModel

    var body: some View {
        HStack {
            StatBadge(value: model.followerCount, label: "Abonnés")
            StatBadge(value: model.posts.count, label: "Posts")
        }
    }
}
Propriétés calculées coûteuses

Les propriétés calculées de @Observable sont réévaluées à chaque accès. Pour des calculs complexes, envisagez de mettre en cache le résultat dans une propriété stockée.

Batch updates avec withObservationTracking

Pour des scénarios avancés, withObservationTracking permet de détecter les changements sans créer de binding :

ObservationTracking.swiftswift
import Observation

@Observable
class DataSyncModel {
    var lastSyncDate: Date?
    var pendingChanges: Int = 0
    var isSyncing: Bool = false
}

class SyncCoordinator {
    let model: DataSyncModel

    init(model: DataSyncModel) {
        self.model = model
        startObserving()
    }

    private func startObserving() {
        // Observe les changements sans UI
        withObservationTracking {
            // Accès qui crée les dépendances
            _ = model.pendingChanges
            _ = model.isSyncing
        } onChange: {
            // Appelé quand une propriété observée change
            Task { @MainActor in
                self.handleModelChange()
            }
        }
    }

    private func handleModelChange() {
        if model.pendingChanges > 0 && !model.isSyncing {
            // Déclenche la synchronisation
            Task {
                await syncChanges()
            }
        }
        // Ré-établit l'observation
        startObserving()
    }

    private func syncChanges() async {
        model.isSyncing = true
        // Logique de sync...
        model.isSyncing = false
        model.pendingChanges = 0
        model.lastSyncDate = Date()
    }
}

Migration depuis ObservableObject

Pour les projets existants utilisant ObservableObject, la migration vers @Observable simplifie le code :

MigrationExample.swiftswift
// ❌ Ancien pattern avec ObservableObject
class OldSettingsModel: ObservableObject {
    @Published var darkMode: Bool = false
    @Published var fontSize: CGFloat = 16
    @Published var notifications: Bool = true
}

struct OldSettingsView: View {
    @StateObject private var settings = OldSettingsModel()
    // ou @ObservedObject si injecté

    var body: some View {
        Form {
            Toggle("Mode sombre", isOn: $settings.darkMode)
            Slider(value: $settings.fontSize, in: 12...24)
            Toggle("Notifications", isOn: $settings.notifications)
        }
    }
}

// ✅ Nouveau pattern avec @Observable
@Observable
class NewSettingsModel {
    var darkMode: Bool = false
    var fontSize: CGFloat = 16
    var notifications: Bool = true
}

struct NewSettingsView: View {
    @State private var settings = NewSettingsModel()

    var body: some View {
        Form {
            Toggle("Mode sombre", isOn: $settings.darkMode)
            Slider(value: $settings.fontSize, in: 12...24)
            Toggle("Notifications", isOn: $settings.notifications)
        }
    }
}

Les avantages de la migration :

  • Plus besoin de @Published sur chaque propriété
  • @State remplace @StateObject pour la création
  • Observation granulaire automatique
  • Code plus lisible et maintenable

Règles de décision pratiques

Voici un guide décisionnel pour choisir le bon outil :

DecisionGuide.swiftswift
/*
 RÈGLE 1 : État UI éphémère → @State
 - Animations, transitions
 - États de formulaires locaux
 - Sélections temporaires
 - Expansion/collapse de sections
*/
struct AnimatedCard: View {
    @State private var isFlipped = false  // ✅ État UI local
    // ...
}

/*
 RÈGLE 2 : Données partagées entre vues → @Observable
 - Modèles de données métier
 - État d'authentification
 - Panier d'achat
 - Paramètres utilisateur
*/
@Observable
class UserSession {  // ✅ Partagé dans l'app
    var user: User?
    var preferences: Preferences
    // ...
}

/*
 RÈGLE 3 : Struct simple avec binding → @State
 - Configuration locale
 - Formulaires isolés
*/
struct FormData {
    var name: String = ""
    var email: String = ""
}

struct FormView: View {
    @State private var formData = FormData()  // ✅ Struct avec @State
    // ...
}

/*
 RÈGLE 4 : Logique métier complexe → @Observable
 - Validations
 - Appels réseau
 - Transformations de données
*/
@Observable
class OrderProcessor {  // ✅ Logique complexe
    var items: [OrderItem] = []
    var status: OrderStatus = .draft

    func validate() -> [ValidationError] { /* ... */ }
    func submit() async throws { /* ... */ }
}

Conclusion

Le choix entre @Observable et @State se résume à deux questions fondamentales : le type de données (valeur ou référence) et le scope de l'état (local ou partagé). @State excelle pour l'état UI simple et local, tandis que @Observable brille pour les modèles de données complexes nécessitant une observation granulaire.

Checklist de décision

  • ✅ Utiliser @State pour les types valeur et l'état UI éphémère
  • ✅ Utiliser @Observable pour les classes avec données métier
  • ✅ Préférer @Observable quand l'état traverse plusieurs vues
  • ✅ Extraire en sous-vues pour optimiser les re-renders
  • ✅ Éviter de lire des propriétés non nécessaires dans le body
  • ✅ Migrer progressivement depuis ObservableObject
  • ✅ Utiliser l'environnement pour l'injection de dépendances
  • ✅ Tester les performances avec Instruments pour les cas complexes

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Tags

#swiftui
#ios
#observable
#state-management
#swift

Partager

Articles similaires