iOS 17 Observation Framework: A Modern Approach to State Management with @Observable

  • Jul 8, 2025

iOS 17 Observation Framework: A Modern Approach to State Management with @Observable

  • DevTechie Inc

Introduced in iOS 17, the Observation framework represents a significant evolution in Swift’s approach to reactive programming and state management. This framework provides a robust, type-safe, and performant implementation of the observer design pattern, offering substantial advantages over the traditional Combine-based approach with ObservableObject and @Published properties.

Key Advantages

The Observation framework offers several compelling benefits:

Reduced Boilerplate: Eliminates the need for @Published property wrappers and manual publisher management

Better Performance: More efficient change tracking and notification system with granular observation

Type Safety: Compile-time guarantees for observable properties without protocol conformance overhead

Simplified Architecture: Reduces coupling between observable objects and their observers

Automatic Dependency Tracking: SwiftUI automatically tracks which properties your views depend on, updating only when necessary

Moving from ObservableObject to Observation 

When migrating from Combine backed ObservableObject to the Observation framework, several changes are essential:

1. @Published Properties → Regular Properties

Before (Combine):

class ExpenseStore: ObservableObject {
    @Published var expenses: [Expense] = []
    @Published var searchText: String = ""
    @Published var isLoading: Bool = false
}

After (Observation):

@Observable
class ExpenseStore {
    var expenses: [Expense] = []
    var searchText: String = ""
    var isLoading: Bool = false
}

With the introduction of Observation framework, the @Observable macro automatically generates the necessary infrastructure to support observation, removing the need for manually wrapping properties with @Published. This streamlines your code, as the framework takes care of change notifications behind the scenes. Unlike @Published, which creates a separate Publisher for each property and increases memory overhead, @Observable is more efficient and lightweight. Additionally, it eliminates the need to explicitly call objectWillChange.send(), simplifying reactive programming in Swift even further.

2. @StateObject → @State

Before (Combine):

struct ContentView: View {
    @StateObject private var expenseStore = ExpenseStore()
    
    var body: some View {
        // View content
    }
}

After (Observation):

struct ContentView: View {
    @State private var expenseStore = ExpenseStore()
    
    var body: some View {
        // View content
    }
}

The StateObject was originally designed to manage the lifecycle of types conforming to ObservableObject. However, with the introduction of the @Observable macro and automatic observation, @State now works seamlessly with @Observableclasses as well. This leads to a simplified ownership model, where there's no longer a need to distinguish between state objects and regular state. As a result, SwiftUI can more effectively optimize state management, leading to improved performance and cleaner, more maintainable code.

3. @ObservedObject → @Bindable

Before (Combine):

struct ExpenseDetailView: View {
    @ObservedObject var expense: Expense
    
    var body: some View {
        TextField("Name", text: $expense.name)
    }
}

After (Observation):

struct ExpenseDetailView: View {
    @Bindable var expense: Expense
    
    var body: some View {
        TextField("Name", text: $expense.name)
    }
}

When working with @Observable types, there's no need to use @ObservedObject anymore. Instead, Swift now provides @Bindable, which enables two-way binding capabilities for observable objects. This approach is more explicit about your intent—you're not just observing changes, you're actively creating bindings. Additionally, it offers better performance by avoiding the creation of unnecessary observation relationships, resulting in more efficient and predictable UI updates.

Building an Expense Tracking App

Let’s explore the Observation framework by building a practical expense tracking application that demonstrates its capabilities.

Data Model Implementation

We’ll start with a basic expense model. Initially, this class won’t work directly with SwiftUI because the properties aren’t bindable:

final class Expense: Identifiable {
    var id: UUID = UUID()
    var name: String
    var amount: Int
    
    init(name: String = "", amount: Int = 0) {
        self.name = name
        self.amount = amount
    }
}

To make this model observable, we need to import the Observation framework and apply the @Observable macro:

import Observation
@Observable
final class Expense: Identifiable {
    var id: UUID = UUID()
    var name: String
    var amount: Int
    
    init(name: String = "", amount: Int = 0) {
        self.name = name
        self.amount = amount
    }
}

Advanced Observable Store

Let’s create a more comprehensive store that demonstrates the power of the Observation framework:

@Observable
final class ExpenseStore {
    var expenses: [Expense] = []
    var searchText: String = ""
    var isLoading: Bool = false
    
    // Computed properties are automatically observable
    var filteredExpenses: [Expense] {
        var filtered = expenses
        
        if !searchText.isEmpty {
            filtered = filtered.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
        }
        
        return filtered
    }
    
    var totalAmount: Int {
        filteredExpenses.reduce(0) { $0 + $1.amount }
    }
    
    var averageExpense: Double {
        guard !filteredExpenses.isEmpty else { return 0 }
        return Double(totalAmount) / Double(filteredExpenses.count)
    }
    
    // Async methods work seamlessly
    func loadExpenses() async {
        isLoading = true
        defer { isLoading = false }
        
        // Simulate network call
        try? await Task.sleep(for: .seconds(1))
        
        // Update will automatically notify observers
        expenses = [
            Expense(name: "Coffee", amount: 5),
            Expense(name: "Lunch", amount: 12),
            Expense(name: "Gas", amount: 45)
        ]
    }
}

Creating the SwiftUI View

Now let’s build our expense tracking interface. Let’s first create a reusable component for the expense row:

struct ExpenseRow: View {
    let expense: Expense
    
    var body: some View {
        HStack {
            VStack(alignment: .leading, spacing: 4) {
                Text(expense.name)
                    .font(.headline)
                    .foregroundStyle(.primary)
                
                Text("Expense")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
            
            Spacer()
            
            Text(expense.amount.formatted(.currency(code: "USD")))
                .font(.title3)
                .fontWeight(.medium)
                .foregroundStyle(.primary)
        }
        .padding(.vertical, 4)
    }
}

Next, build a view to list all the current expenses along with a section to add new expenses:

struct ExpenseTracker: View {
    @State private var expenseStore = ExpenseStore()
    @State private var newExpense = Expense()
    
    var body: some View {
        NavigationStack {
            List {
                searchSection
                expenseList
                addExpenseSection
            }
            .navigationTitle("Expense Tracker")
            .safeAreaInset(edge: .bottom) {
                totalExpenseView
            }
            .task {
                await expenseStore.loadExpenses()
            }
        }
    }
    
    // MARK: - View Components
    
    @ViewBuilder
    private var searchSection: some View {
        Section {
            TextField("Search expenses", text: $expenseStore.searchText)
                .textFieldStyle(.roundedBorder)
        }
    }
    
    @ViewBuilder
    private var expenseList: some View {
        ForEach(expenseStore.filteredExpenses) { expense in
            ExpenseRow(expense: expense)
        }
        .onDelete(perform: deleteExpenses)
    }
    
    @ViewBuilder
    private var addExpenseSection: some View {
        Section {
            VStack(spacing: 12) {
                HStack {
                    TextField("Expense name", text: $newExpense.name)
                        .textFieldStyle(.roundedBorder)
                    
                    TextField("Amount", value: $newExpense.amount, format: .number)
                        .textFieldStyle(.roundedBorder)
                        .keyboardType(.numberPad)
                }
                
                Button("Add Expense") {
                    addExpense()
                }
                .buttonStyle(.borderedProminent)
                .disabled(newExpense.name.isEmpty || newExpense.amount <= 0)
            }
            .padding(.vertical, 8)
        }
    }
    
    @ViewBuilder
    private var totalExpenseView: some View {
        VStack(spacing: 8) {
            HStack {
                Text("Total: ")
                    .foregroundStyle(.secondary)
                
                Text(expenseStore.totalAmount.formatted(.currency(code: "USD")))
                    .font(.title2)
                    .fontWeight(.semibold)
                    .foregroundStyle(.primary)
            }
            
            HStack {
                Text("Average: ")
                    .foregroundStyle(.secondary)
                
                Text(expenseStore.averageExpense.formatted(.currency(code: "USD")))
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
        }
        .padding()
        .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
        .padding(.horizontal)
    }
    
    // MARK: - Actions
    
    private func addExpense() {
        guard !newExpense.name.isEmpty, newExpense.amount > 0 else { return }
        
        expenseStore.expenses.append(newExpense)
        newExpense = Expense()
    }
    
    private func deleteExpenses(at offsets: IndexSet) {
        let filteredExpenses = expenseStore.filteredExpenses
        let expensesToDelete = offsets.map { filteredExpenses[$0] }
        
        expenseStore.expenses.removeAll { expense in
            expensesToDelete.contains { $0.id == expense.id }
        }
    }
}

Observation Benefits

Granular Observation

The Observation framework provides significant performance improvements through granular observation:

@Observable
class UserProfile {
    var name: String = ""
    var email: String = ""
    var avatar: UIImage? = nil
    var preferences: UserPreferences = UserPreferences()
}

struct ProfileView: View {
    @State private var profile = UserProfile()
    
    var body: some View {
        VStack {
            // This view only updates when 'name' changes
            Text("Hello, \(profile.name)")
            
            // This view only updates when 'email' changes
            Text(profile.email)
        }
    }
}

With Observable, SwiftUI now tracks exactly which properties each view depends on, allowing for more precise and efficient updates. When a property changes, only the views that rely on that specific property are re-rendered, avoiding unnecessary updates across the UI. This improves performance and eliminates the redundant re-renders that often occurred with @Published. Additionally, SwiftUI manages memory more effectively by automatically cleaning up observers, resulting in a leaner and more responsive app architecture.

Reduced Memory Footprint

Before (Combine)

// Each @Published property creates a Publisher
class ExpenseStore: ObservableObject {
    @Published var expenses: [Expense] = []           // Creates PassthroughSubject
    @Published var searchText: String = ""            // Creates PassthroughSubject
    @Published var isLoading: Bool = false            // Creates PassthroughSubject
    @Published var selectedCategory: Category = .all  // Creates PassthroughSubject
    
    // 4 Publishers + ObjectWillChange Publisher = 5 total publishers
}

After (Observation)

// Single observation mechanism
@Observable
class ExpenseStore {
    var expenses: [Expense] = []
    var searchText: String = ""
    var isLoading: Bool = false
    var selectedCategory: Category = .all
    
    // Single, efficient observation system
}

Best Practices and Considerations

When to Use @Observable vs @ObservableObject

Use @Observable when:

  • Building new iOS 17+ applications

  • You want better performance and less boilerplate

  • Working with simple to moderate state complexity

  • You need granular observation capabilities

Stick with @ObservableObject when:

  • Supporting iOS versions below 17

  • You have complex publisher chains that are difficult to migrate

  • Working with third-party libraries that expect ObservableObject

Conclusion

The Observation framework in iOS 17 represents a significant step forward in Swift’s reactive programming capabilities. By eliminating boilerplate code, improving performance, and providing better type safety, it makes building reactive SwiftUI applications more straightforward and maintainable.

Key Takeaways:

  • Replace @Published with regular properties in @Observable classes for automatic change tracking

  • Use @State instead of @StateObject for better performance and simplified ownership

  • Use @Bindable instead of @ObservedObject when you need two-way binding

  • Leverage automatic dependency tracking for granular updates and better performance

  • Migrate gradually by converting one store at a time

  • Consider iOS version requirements when planning your migration strategy

This modern approach to state management will help you build more efficient and maintainable iOS applications while reducing complexity and improving developer experience. The Observation framework represents the future of reactive programming in SwiftUI, offering a cleaner, more performant alternative to traditional Combine-based solutions.