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.

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.
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.
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 :
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.
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 :
@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
}
}
}
}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 :
// 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.
@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 :
@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 :
// ❌ 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")
}
}
}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 :
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 :
// ❌ 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
@Publishedsur chaque propriété @Stateremplace@StateObjectpour 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 :
/*
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
@Statepour les types valeur et l'état UI éphémère - ✅ Utiliser
@Observablepour les classes avec données métier - ✅ Préférer
@Observablequand 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
Partager
Articles similaires

SwiftUI Performance : optimiser LazyVStack et listes complexes
Techniques d'optimisation pour LazyVStack et listes SwiftUI. Réduire la consommation mémoire, améliorer le scrolling et éviter les pièges de performance courants.

SwiftUI Custom ViewModifiers : patterns réutilisables pour design system
Créer des ViewModifiers SwiftUI personnalisés pour un design system cohérent. Patterns, bonnes pratiques et exemples concrets pour styliser vos vues iOS efficacement.

SwiftUI : Construire des interfaces modernes pour iOS
Apprenez à créer des interfaces utilisateur modernes avec SwiftUI : syntaxe déclarative, composants, animations et bonnes pratiques pour iOS 18.