To make a SwiftUI list row reflect edits inside an ObservableObject item, pass that item to a row view with @ObservedObject and publish its mutable properties. This guide builds a small Mac label editor and explains the separate roles of collection updates, row observation, and identity.

Why does changing an item leave the row unchanged?

Consider a store with an @Published var items: [LabelItem] property, where each LabelItem is a class. Appending an item changes the array. Assigning a new title to an existing item changes the referenced object. Those are different mutations: Swift arrays are value types, while class instances have reference semantics. An array of class references still contains references to shared instances. Value And Reference Types In Swift.

The practical consequence is that publishing the array does not automatically establish observation of every property inside every contained object. Apple’s migration guide identifies tracking collections of objects as a benefit of the newer Observation system. For an existing ObservableObject implementation, give each row its own connection to the object it displays. Migrating from the Observable Object protocol to the Observable macro.

A useful way to diagnose the problem is to identify two responsibilities: the containing view observes which items belong in the list; each row observes the mutable content of its item. Apple documents @ObservedObject as the wrapper for an incoming ObservableObject whose published changes should update the receiving view. ObservedObject.

Build a complete macOS example with an observed row

Use this version when your app already uses Combine’s ObservableObject, including when it supports macOS versions earlier than 14. The example requires macOS 11 or later because its owner uses @StateObject, which Apple lists as available from macOS 11. Use a current Xcode Swift compiler. StateObject.

Create a macOS app target with the SwiftUI interface. Replace the generated app entry file with the complete program below, and remove the generated content view if you no longer need it. Keep exactly one @main declaration in the target. All example labels are synthetic, and all interactions happen inside the app’s own window.

import SwiftUI
import Combine
import Foundation

@MainActor
final class LabelItem: ObservableObject, Identifiable {
    let id = UUID()
    @Published var title: String
    @Published var isEnabled: Bool

    init(title: String, isEnabled: Bool = true) {
        self.title = title
        self.isEnabled = isEnabled
    }
}

@MainActor
final class LabelStore: ObservableObject {
    @Published var items: [LabelItem] = [
        LabelItem(title: "Amber"),
        LabelItem(title: "Cobalt", isEnabled: false)
    ]

    func reviseFirstItem() {
        guard let first = items.first else { return }
        first.title = first.title == "Amber" ? "Copper" : "Amber"
        first.isEnabled.toggle()
    }

    func addItem() {
        items.append(LabelItem(title: "New label"))
    }

    func removeLastItem() {
        guard !items.isEmpty else { return }
        items.removeLast()
    }
}

@MainActor
struct LabelRow: View {
    @ObservedObject var item: LabelItem

    var body: some View {
        HStack {
            TextField("Label", text: $item.title)
                .textFieldStyle(.roundedBorder)
                .frame(minWidth: 120)

            Text(item.title)
                .frame(minWidth: 100, alignment: .leading)

            Toggle("Enabled", isOn: $item.isEnabled)
        }
        .padding(.vertical, 4)
    }
}

@MainActor
struct LabelListView: View {
    @StateObject private var store = LabelStore()

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                Button("Change first label") {
                    store.reviseFirstItem()
                }
                .disabled(store.items.isEmpty)

                Button("Add label") {
                    store.addItem()
                }

                Button("Remove last") {
                    store.removeLastItem()
                }
                .disabled(store.items.isEmpty)
            }

            Text("Rows: \(store.items.count)")

            List(store.items) { item in
                LabelRow(item: item)
            }
        }
        .padding()
        .frame(minWidth: 540, minHeight: 300)
    }
}

@main
@MainActor
struct LabelRowsApp: App {
    var body: some Scene {
        WindowGroup {
            LabelListView()
        }
    }
}

The store owns the item instances. LabelListView creates and retains the store using @StateObject; the rows receive existing instances using @ObservedObject. Apple explains that a state object persists for the lifetime of its declaring container’s identity, and that an observed object should normally be an input rather than a newly created default value. StateObject, ObservedObject.

The text field and toggle use bindings to the item’s published properties. The adjacent text reads the same title, giving you a visible check that editing and display share one model. @MainActor keeps this example’s model and interface operations on the main actor, whose executor Apple describes as equivalent to the main dispatch queue. ObservedObject, MainActor.

This program was inspected against the documented APIs but was not compiled here: the available environment has no Swift toolchain. Build and run it in Xcode to verify its behavior on your deployment target.

Verify item edits separately from collection changes

Use the following sequence to distinguish the two update paths. These are expected results derived from the program, rather than reported test measurements.

  1. Launch the app. It should display two rows, initially labeled Amber and Cobalt.
  2. Click Change first label. The first row should show Copper, and its Enabled toggle should change. The row count should remain two.
  3. Edit the first row’s Label field. The adjacent text should reflect the same title.
  4. Click Add label. A third row named New label should appear, and the count should increase.
  5. Click Remove last until the list is empty. The removal and first-label buttons should become disabled; adding a label should remain available.

The second step is particularly useful because the mutation originates in the containing view’s button action. It changes the existing item without replacing the array. A successful row update therefore checks the item observation path directly. The insertion and removal steps exercise the store’s published array separately.

If insertion works but title changes do not, inspect the property declaration in the row that reads the title. In this architecture it must receive the item through @ObservedObject, and the mutable title must be @Published. Publishing is attached to the property carrying that wrapper; it is not a recursive declaration for an entire object graph. Published, ObservedObject.

Diagnose the remaining failures in a useful order

Confirm that the writer and row share an instance

Trace the object passed to LabelRow(item:) and the object modified by the action. Creating another LabelItem with the same title creates a separate instance. Similarly, copying the title into local row state creates another value to keep synchronized. For this editor, bind directly to the shared item unless a deliberate draft-and-commit workflow is required. This follows from Swift’s documented distinction between copying values and sharing class references. Value And Reference Types In Swift.

Keep identity stable while content changes

The example gives each item a stored, immutable identifier. ForEach, which underlies common dynamic list construction, requires identifiable elements or an explicit identifier. Identity lets SwiftUI associate generated content with the corresponding data element across updates. It does not replace the row’s observation connection. ForEach.

Avoid adding a changing .id(UUID()) as a routine refresh mechanism. Apple documents that changing a view’s identity resets its state, including focus state. Recreating a row to display an edited title adds a separate lifecycle change to a content edit; establish the observation path first. StateObject.

Treat parent summaries as additional consumers

Suppose you add an enabled-item count above the list. With the legacy design shown here, observing LabelStore does not automatically make the parent observe each item’s isEnabled publisher. Fixing the rows alone therefore does not establish the dependencies for that summary.

Choose its update design explicitly: aggregate child notifications in the store, use value-type items whose edits write back through the published collection, or adopt Observation when your deployment target permits it. These are architectural options derived from the collection-versus-item distinction; the sample implements only direct row observation. Apple’s migration guide describes the newer system’s ability to track collections and the properties actually read by a view. Migrating from the Observable Object protocol to the Observable macro.

If you implement publisher-based aggregation, account for notification timing. Apple specifies that @Published publishes during willSet: a subscriber receives the incoming value before the stored property changes. Reading the property again inside that callback can return its previous value. Use the emitted value when calculating from that event. Published.

When should you switch to Observation on macOS 14 or later?

For a minimum deployment target of macOS 14 or later, SwiftUI supports the Observation system. With an @Observable model, SwiftUI tracks observable property reads during view evaluation. Apple’s guide specifically demonstrates a collection of observable objects whose individual displayed titles update when those titles change. Managing model data in your app.

To migrate this example, update both LabelItem and LabelStore to use @Observable with import Observation, remove their ObservableObject conformances, and remove @Published. Change the owning store property from @StateObject to @State. Because this row contains editable controls, change its incoming item wrapper from @ObservedObject to @Bindable. These changes follow Apple’s documented migration path. Migrating from the Observable Object protocol to the Observable macro.

A display-only row can receive an observable item through an ordinary stored property and read its title in body. @Bindable supplies the bindings needed by controls such as TextField and Toggle; it serves a different purpose from @ObservedObject. Do not wrap a model that only uses the new Observation system in @ObservedObject, whose generic constraint requires ObservableObject. Bindable, ObservedObject.

The decision is straightforward: keep the observed-row solution to repair an existing Combine model with its current deployment range; consider Observation when macOS 14 is an acceptable minimum and property-level dependency tracking suits the rest of the interface.

What changes if the row embeds an AppKit text view?

The SwiftUI example uses standard controls and needs no custom TextKit integration. If your own row already wraps an AppKit view with NSViewRepresentable, check a second boundary after confirming model observation: Apple directs you to update the AppKit view’s configuration in updateNSView(_:context:) when corresponding SwiftUI state changes. Creating the native view once does not implement subsequent configuration updates. updateNSView(_:context:).

For an embedded NSTextView, distinguish that model-to-view synchronization from text layout. Apple describes NSTextView as the front end of the AppKit text system and documents a compatibility switch when its layoutManager property is explicitly accessed on macOS 12 or later. Avoid introducing layout-engine changes while diagnosing a missing row observation connection. NSTextView.

For the label editor, the concrete fix is complete at the row boundary: retain the store, publish editable item properties, and observe each incoming item where it is displayed. Verify an item mutation without changing the collection, then verify insertion and removal. That sequence gives you a clear answer about which connection needs attention.

Sources