← All topics/UIKit & SwiftUI internals

Question 9

Modals: ownership, boundaries, and passing data back

You need to show a second screen on top of the current one and hand information back when it finishes (or cancel without changing anything). How do you decide where state lives, how the two screens should talk to each other, and what usually goes wrong if that contract is vague?

Answer outline

The presenting screen owns whether the modal is showing and what happens when it closes. The presented screen is where the user edits a draft, picks an option, or confirms. Keep that split explicit, because a child that silently mutates the parent's model while open is where the subtle bugs start.

For data in, pass an initial value or a reference to shared state, whatever the child needs to start. If both sides should reflect edits immediately, they must share the same draft or store rather than hold two copies.

For data out, agree on an explicit contract. Either a callback or delegate returns one result that the parent applies on dismiss, or a binding or shared observable carries edits live because both screens have a single source of truth. When that contract is vague, four things go wrong:

  1. 1.Two copies: the parent and the modal each hold the same field, and the two drift apart.
  2. 2.Retain cycles: the presenter and the modal hold each other through closures or delegates.
  3. 3.Saving after cancel: the user tapped cancel, but edits already leaked into the parent or a save still ran.
  4. 4.Late writes: async work that finishes after dismiss and still writes into the model, or a result from a stacked presentation landing on the wrong owner.

Principles

  • Decide commit or live first, meaning whether the parent changes only when the modal completes or while it's still open.
  • A commit-on-finish modal wants a callback or delegate, and a live editor wants a binding or one shared model.
  • Use one return path per piece of data, never a delegate, a closure, and a shared model all at once.
  • Cancel must leave the parent exactly as it was, so edit a draft copy and apply it only on finish.

The child finishes with one outcome, a saved value or cancel, and the parent applies it in one place:

Result on dismiss (explicit contract)
struct Parent: View {
    @State private var item = Item.default
    @State private var editorPresented = false

    var body: some View {
        ContentView()
            .sheet(isPresented: $editorPresented) {
                ItemEditor(
                    initial: item,
                    onFinish: { updated in
                        item = updated
                        editorPresented = false
                    },
                    onCancel: { editorPresented = false }
                )
            }
    }
}

When the modal is a live editor for shared data, one shared source (an observable, a view model, or a binding) avoids copied fields that drift out of sync:

Single source of truth while open
struct LiveItemEditor: View {
    @Binding var item: Item

    var body: some View {
        // edits write directly into the parent's item while the sheet is visible
        TextField("Name", text: $item.name)
    }
}