Confirming SwiftData's reactivity

This should be a short one. I’m writing this many days after I encountered the issue and now I can’t remember the context in which it came up. But, let’s use this as an excuse to either confirm or correct my understanding.

I assumed that objects fetched through @Query properties in SwiftData are observable such that if I were to pass an object down through some view hierarchy, mutate the object in some way, then flush the changes, any view that is observing this class of objects should react and refresh with this new data.

We’ll be working with a simple list of todos that we can create and mutate but not delete (to keep it simple).

The sheet view that pops up for creating and editing is its own component, and when it receives a todo item as a parameter, it’s assumed that we’ll be mutating that object

My hypothesis, spelled out, is that the todo item that’s passed in requires no special property wrapper in order for changes to it to be reflected in the main view. So, let’s see what happens with the following code:


import SwiftUI
import SwiftData

@main
struct BingingTestApp: App {
    var sharedModelContainer: ModelContainer = {
        let schema = Schema([
            Todo.self,
        ])
        let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)

        do {
            return try ModelContainer(for: schema, configurations: [modelConfiguration])
        } catch {
            fatalError("Could not create ModelContainer: (error)")
        }
    }()

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(sharedModelContainer)
    }
}


@Model
final class Todo {
    var timestamp: Date
    var text: String
    
    init(timestamp: Date, text: String) {
        self.timestamp = timestamp
        self.text = text
    }
}


struct TodoForm: View {
    @Environment(.dismiss) private var dismiss

    let saveTodo: (_ todoText: String) -> Void
    
    @State private var todoTextInput: String = ""
    
    var body: some View {
        VStack(alignment: .leading) {
            Text("Add an item to your todo list")
            TextField("Todo", text: $todoTextInput)
                .textFieldStyle(.roundedBorder)
            HStack {
                Spacer()
                Button("Save") {
                    saveTodo(todoTextInput)
                    dismiss()
                }
                .buttonStyle(.borderedProminent)
            }
        }
        .padding()
    }
}

struct TodoDetailView: View {
    var todo: Todo
    let saveTodo: (_ todo: Todo) -> Void

    @State private var showTodoEditorSheet: Bool = false
    @State private var todoTextInput: String = ""
    
    @Environment(.dismiss) private var dismiss
    
    
    var body: some View {
        VStack {
            Text("(todo.text)")
        }
        .toolbar {
            ToolbarItem {
                Button {
                    showTodoEditorSheet = true
                } label: {
                    Image(systemName: "pencil.circle")
                }
            }
        }
        .sheet(isPresented: $showTodoEditorSheet) {
            VStack(alignment: .leading) {
                Text("Update your todo list")
                TextField("Todo", text: $todoTextInput)
                    .textFieldStyle(.roundedBorder)
                HStack {
                    Spacer()
                    Button("Save") {
                        todo.text = todoTextInput
                        saveTodo(todo)
                        dismiss()
                    }
                    .buttonStyle(.borderedProminent)
                }
            }
            .padding()
        }
        .onAppear {
            todoTextInput = todo.text
        }
    }
}

struct ContentView: View {
    @Environment(.modelContext) private var modelContext
    @Query private var todos: [Todo]
    
    @State private var showTodoEditorSheet: Bool = false
    @State private var selectedTodo: Todo? = nil

    var body: some View {
        NavigationStack {
            VStack {
                Button {
                    showTodoEditorSheet = true
                } label: {
                    Label("Add todo", systemImage: "plus.circle")
                }
                List(todos) { todo in
                    NavigationLink {
                        TodoDetailView(todo: todo, saveTodo: saveTodo)
                    } label: {
                        HStack {
                            Text(todo.text)
                            Spacer()
                        }
                    }
                }
            }
            .sheet(isPresented: $showTodoEditorSheet, onDismiss: { selectedTodo = nil }) {
                TodoForm(saveTodo: addTodo)
                    .presentationDetents([.medium])
            }
        }
        
    }
    
    func saveTodo(todo: Todo) {
        modelContext.insert(todo)
    }

    func addTodo(todoText: String) {
        withAnimation {
            let newTodo = Todo(timestamp: Date(), text: todoText)
            modelContext.insert(newTodo)
        }
    }

    private func deleteItem(todo: Todo) {
        withAnimation {
            modelContext.delete(todo)
        }
    }
}

#Preview {
    ContentView()
        .modelContainer(for: Todo.self, inMemory: true)
}        

As you can see from the screenshot, modifying the object passed to a new view, in this instance, from the home view to the navigation stack to the sheet, updates the view when we return to the home screen.

Demonstration of todo app where mutations propagate to all observing views.