Data flow between screens: from UIKit injection to SwiftUI's @Observable
Passing data forward and back between UIKit view controllers without singletons, sharing state in SwiftUI with @Observable, @State, @Environment and @Bindable, value-based navigation, and what changed about @State initial values in Xcode 27.
As soon as an app has a second screen, data has to cross from one screen to the other: a list hands an order to a detail screen, an editor sends a result back, and several screens show the same cart. When it is unclear who owns that data, the symptoms are predictable: implicitly unwrapped properties that crash when nobody set them, retain cycles between parent and child, and SwiftUI views that do not update or that recreate their model at the wrong time.
Underneath, this is a matter of ownership and dependency injection. This article walks through the patterns I recommend for UIKit, then the Observation framework that since iOS 17 makes the SwiftUI side much simpler than it was with ObservableObject, and finally the pitfalls, including a 2026 change to how @State evaluates its initial value.
Three kinds of data flow
It helps to separate three different situations:
- Forward: screen A has an
Orderand screen B needs to show it. - Backward: screen B edits something and A needs the result.
- Shared: several screens show and change the same state, such as a shopping cart.
It is tempting to solve all three with the same tool, usually a singleton or NotificationCenter. Both work, and both make the code hard to follow six months later. Each situation has a better-fitting pattern.
UIKit: initializers, closures and delegates
Forward: inject through the initializer
The cleanest option is the one the compiler can check: a let property and an initializer.
final class OrderDetailViewController: UIViewController {
private let order: Order
init(order: Order) {
self.order = order
super.init(nibName: nil, bundle: nil)
}
// Storyboard variant, used with instantiateViewController(identifier:creator:)
init?(coder: NSCoder, order: Order) {
self.order = order
super.init(coder: coder)
}
required init?(coder: NSCoder) {
fatalError("Use init(order:) or init(coder:order:)")
}
}
If the screen lives in a storyboard, you no longer need an implicitly unwrapped var order: Order! that you fill in later. Since iOS 13, UIStoryboard can call your initializer:
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let detail = storyboard.instantiateViewController(identifier: "OrderDetail") { coder in
OrderDetailViewController(coder: coder, order: order)
}
navigationController?.pushViewController(detail, animated: true)
prepare(for:sender:) still works and is fine for segue-heavy legacy code. The problem is that it fills a property after the object already exists, so the destination has to handle an "empty" state that should not be possible. I recommend avoiding it in new code.
Backward: closure or delegate
For a single result, such as "the user saved a new title", a closure is the least ceremony:
let editor = EditTitleViewController(initialTitle: order.title)
editor.onSave = { [weak self] newTitle in
self?.rename(order, to: newTitle)
}
present(UINavigationController(rootViewController: editor), animated: true)
When the child reports several related events (saved, cancelled, deleted, validation failed), a delegate protocol is the better fit. It documents the contract in one place, and a weak var delegate makes the ownership obvious:
protocol EditTitleViewControllerDelegate: AnyObject {
func editTitleViewController(_ controller: EditTitleViewController, didSave title: String)
func editTitleViewControllerDidCancel(_ controller: EditTitleViewController)
}
Both approaches have one rule: the child must not keep the parent alive. Make the delegate weak and use [weak self] in a stored closure.
SwiftUI with Observation (iOS 17+)
The Observation framework and the @Observable macro arrived in iOS 17, and they make most of these ownership questions much simpler. When migrating, this mapping is a good starting point:
| Before (iOS 13–16) | Now (iOS 17+) |
|---|---|
class Model: ObservableObject + @Published var |
@Observable class Model with plain var |
@StateObject var model = Model() |
@State var model = Model() |
@ObservedObject var model: Model |
let model: Model or @Bindable var model: Model |
.environmentObject(model) + @EnvironmentObject |
.environment(model) + @Environment(Model.self) |
A small but complete example: a cart owned at the top level and shared through the environment.
import SwiftUI
import Observation
@Observable
final class CartItem: Identifiable {
let id = UUID()
var name: String
var quantity: Int
var note = ""
init(name: String, quantity: Int = 1) {
self.name = name
self.quantity = quantity
}
}
@Observable
final class Cart {
var items: [CartItem] = []
var totalQuantity: Int { items.reduce(0) { $0 + $1.quantity } }
}
@main
struct ShopApp: App {
@State private var cart = Cart() // the owner
var body: some Scene {
WindowGroup {
CartScreen()
.environment(cart) // iOS 17+
}
}
}
struct CartScreen: View {
@Environment(Cart.self) private var cart // iOS 17+
var body: some View {
NavigationStack {
List(cart.items) { item in
NavigationLink(value: item.id) {
HStack {
Text(item.name)
Spacer()
Text("x\(item.quantity)")
}
}
}
.navigationDestination(for: UUID.self) { id in // iOS 16+
if let item = cart.items.first(where: { $0.id == id }) {
CartItemEditor(item: item)
}
}
.toolbar { Text("\(cart.totalQuantity) items") }
}
}
}
struct CartItemEditor: View {
@Bindable var item: CartItem // iOS 17+, gives you $item.quantity
var body: some View {
Form {
Stepper("Quantity: \(item.quantity)", value: $item.quantity, in: 1...99)
TextField("Note", text: $item.note)
}
}
}
A few points worth stressing:
- Read-only children do not need a wrapper. If a view only reads an
@Observableobject, a plainletis enough. SwiftUI tracks the properties thatbodyactually reads. @Bindableis only for bindings. Use it when you need$item.quantityfor aStepperorTextField. For an object from the environment, you can create one locally insidebody:@Bindable var cart = cart.- Navigate with values.
NavigationLink(value:)plusnavigationDestination(for:)means the destination is built when it is needed, from aHashablevalue. Passing an ID and looking it up in the shared model keeps a single source of truth.
Fine-grained tracking and nested objects
With ObservableObject, a parent publishes a change only when its own @Published property is assigned a new value. If items is an array of objects and you change items[3].quantity, the array still holds the same references, so the parent publishes nothing and the list does not update. This is the classic "array not updated" problem.
Observation tracks changes per property, on each object. The row that reads item.quantity depends on that property of that item, so changing it updates the row. You no longer need to forward nested changes by hand.
Pitfalls
The @State initial value, and Xcode 27
This one deserves care, because the behavior changed in 2026.
Up to Xcode 26, Apple's documentation for State says the property always instantiates its default value when SwiftUI instantiates the view. SwiftUI keeps only the first instance as storage, but Model() is evaluated again every time the parent re-creates the view struct, and that can happen often. @StateObject never had this problem, because it takes its initial value as an autoclosure. That made @State private var model = ExpensiveModel() a subtle performance and side-effect trap.
With Xcode 27, @State is a Swift macro instead of a property wrapper. Apple's documentation and technote TN3211 say the initial value is now evaluated lazily, only once, when the view is first created. The behavior depends on the Xcode version you build with, so a team still on Xcode 26 has the old behavior. TN3211 also lists source incompatibilities. One of them: if a @State property has an inline default value and you also assign it in init, the inline value wins and your assignment is discarded.
My rules, which work with both toolchains:
- Keep
@Stateinitial values cheap and free of side effects. - Do expensive work such as network calls, file access or database setup in
.task(iOS 15+), not in initializers. - When a model depends on a parameter, create it in
.task(id:), so a new parameter value actually produces a new model:
@MainActor
@Observable
final class ProfileModel {
let userID: Int
var name = ""
init(userID: Int) { self.userID = userID }
func load() async { /* fetch the profile */ }
}
struct ProfileScreen: View {
let userID: Int
@State private var model: ProfileModel?
var body: some View {
Group {
if let model { ProfileContent(model: model) } else { ProgressView() }
}
.task(id: userID) {
let model = ProfileModel(userID: userID)
self.model = model
await model.load()
}
}
}
That last point is the key to models that depend on a parameter: state is created once per view identity. Passing a different parameter later does not replace it, unless you change the identity (with .id(userID)) or rebuild the model yourself, as above.
Other traps
@Statefor objects you do not own. If a parent passes a model in, the child must not wrap it in@State. That is the old@StateObjectvs@ObservedObjectconfusion with new names.- Environment without an owner. The non-optional
@Environment(Cart.self)assumes some ancestor called.environment(cart). If none did, you find out at runtime, not at compile time. Previews are the usual place this happens. If a missing object is a legitimate case, declare the property asCart?; Apple documents that the optional form returnsnil. - Mixing both worlds. An
ObservableObjectstored in@Stateonly updates the view when the reference changes, not when its@Publishedproperties change. Migrate a model completely.
Summary
- UIKit forward: initializer injection, with
instantiateViewController(identifier:creator:)(iOS 13+) for storyboards. - UIKit backward: a closure for one result, a
weakdelegate for many events. - SwiftUI, iOS 17+:
@Observable+@Stateat the owner,.environment/@Environment(Type.self)to share,@Bindableonly where you need bindings. - Navigate with values through
NavigationStackandnavigationDestination(for:)(iOS 16+). - Keep
@Stateinitial values cheap, move heavy setup to.taskor.task(id:), and remember that Xcode 27 changed when that initial value is evaluated.
Moving data between screens is mostly a question of who owns it. Once that is clear, the right API is usually obvious.
- ---
- baslik: "Data flow between screens: from UIKit injection to SwiftUI's @Observable"
- ozet: "Passing data forward and back between UIKit view controllers without singletons, sharing state in SwiftUI with @Observable, @State, @Environment and @Bindable, value-based navigation, and what changed about @State initial values in Xcode 27."
- tarih: 2026-09-03
- etiketler:
- - ios
- - swift
- - uikit
- - swiftui
- kategoriler:
- - ios
- dil: en
- ---
- As soon as an app has a second screen, data has to cross from one screen to the other: a list hands an order to a detail screen, an editor sends a result back, and several screens show the same cart. When it is unclear who owns that data, the symptoms are predictable: implicitly unwrapped properties that crash when nobody set them, retain cycles between parent and child, and SwiftUI views that do not update or that recreate their model at the wrong time.
- Underneath, this is a matter of ownership and dependency injection. This article walks through the patterns I recommend for UIKit, then the Observation framework that since iOS 17 makes the SwiftUI side much simpler than it was with `ObservableObject`, and finally the pitfalls, including a 2026 change to how `@State` evaluates its initial value.
- ## Three kinds of data flow
- It helps to separate three different situations:
- 1. **Forward:** screen A has an `Order` and screen B needs to show it.
- 2. **Backward:** screen B edits something and A needs the result.
- 3. **Shared:** several screens show and change the same state, such as a shopping cart.
- It is tempting to solve all three with the same tool, usually a singleton or `NotificationCenter`. Both work, and both make the code hard to follow six months later. Each situation has a better-fitting pattern.
- ## UIKit: initializers, closures and delegates
- ### Forward: inject through the initializer
- The cleanest option is the one the compiler can check: a `let` property and an initializer.
- ```swift
- final class OrderDetailViewController: UIViewController {
- private let order: Order
- init(order: Order) {
- self.order = order
- super.init(nibName: nil, bundle: nil)
- }
- // Storyboard variant, used with instantiateViewController(identifier:creator:)
- init?(coder: NSCoder, order: Order) {
- self.order = order
- super.init(coder: coder)
- }
- required init?(coder: NSCoder) {
- fatalError("Use init(order:) or init(coder:order:)")
- }
- }
- ```
- If the screen lives in a storyboard, you no longer need an implicitly unwrapped `var order: Order!` that you fill in later. Since iOS 13, `UIStoryboard` can call your initializer:
- ```swift
- let storyboard = UIStoryboard(name: "Main", bundle: nil)
- let detail = storyboard.instantiateViewController(identifier: "OrderDetail") { coder in
- OrderDetailViewController(coder: coder, order: order)
- }
- navigationController?.pushViewController(detail, animated: true)
- ```
- `prepare(for:sender:)` still works and is fine for segue-heavy legacy code. The problem is that it fills a property after the object already exists, so the destination has to handle an "empty" state that should not be possible. I recommend avoiding it in new code.
- ### Backward: closure or delegate
- For a single result, such as "the user saved a new title", a closure is the least ceremony:
- ```swift
- let editor = EditTitleViewController(initialTitle: order.title)
- editor.onSave = { [weak self] newTitle in
- self?.rename(order, to: newTitle)
- }
- present(UINavigationController(rootViewController: editor), animated: true)
- ```
- When the child reports several related events (saved, cancelled, deleted, validation failed), a delegate protocol is the better fit. It documents the contract in one place, and a `weak var delegate` makes the ownership obvious:
- ```swift
- protocol EditTitleViewControllerDelegate: AnyObject {
- func editTitleViewController(_ controller: EditTitleViewController, didSave title: String)
- func editTitleViewControllerDidCancel(_ controller: EditTitleViewController)
- }
- ```
- Both approaches have one rule: the child must not keep the parent alive. Make the delegate `weak` and use `[weak self]` in a stored closure.
- ## SwiftUI with Observation (iOS 17+)
- The Observation framework and the `@Observable` macro arrived in iOS 17, and they make most of these ownership questions much simpler. When migrating, this mapping is a good starting point:
- | Before (iOS 13–16) | Now (iOS 17+) |
- |---|---|
- | `class Model: ObservableObject` + `@Published var` | `@Observable class Model` with plain `var` |
- | `@StateObject var model = Model()` | `@State var model = Model()` |
- | `@ObservedObject var model: Model` | `let model: Model` or `@Bindable var model: Model` |
- | `.environmentObject(model)` + `@EnvironmentObject` | `.environment(model)` + `@Environment(Model.self)` |
- A small but complete example: a cart owned at the top level and shared through the environment.
- ```swift
- import SwiftUI
- import Observation
- @Observable
- final class CartItem: Identifiable {
- let id = UUID()
- var name: String
- var quantity: Int
- var note = ""
- init(name: String, quantity: Int = 1) {
- self.name = name
- self.quantity = quantity
- }
- }
- @Observable
- final class Cart {
- var items: [CartItem] = []
- var totalQuantity: Int { items.reduce(0) { $0 + $1.quantity } }
- }
- @main
- struct ShopApp: App {
- @State private var cart = Cart() // the owner
- var body: some Scene {
- WindowGroup {
- CartScreen()
- .environment(cart) // iOS 17+
- }
- }
- }
- struct CartScreen: View {
- @Environment(Cart.self) private var cart // iOS 17+
- var body: some View {
- NavigationStack {
- List(cart.items) { item in
- NavigationLink(value: item.id) {
- HStack {
- Text(item.name)
- Spacer()
- Text("x\(item.quantity)")
- }
- }
- }
- .navigationDestination(for: UUID.self) { id in // iOS 16+
- if let item = cart.items.first(where: { $0.id == id }) {
- CartItemEditor(item: item)
- }
- }
- .toolbar { Text("\(cart.totalQuantity) items") }
- }
- }
- }
- struct CartItemEditor: View {
- @Bindable var item: CartItem // iOS 17+, gives you $item.quantity
- var body: some View {
- Form {
- Stepper("Quantity: \(item.quantity)", value: $item.quantity, in: 1...99)
- TextField("Note", text: $item.note)
- }
- }
- }
- ```
- A few points worth stressing:
- - **Read-only children do not need a wrapper.** If a view only reads an `@Observable` object, a plain `let` is enough. SwiftUI tracks the properties that `body` actually reads.
- - **`@Bindable` is only for bindings.** Use it when you need `$item.quantity` for a `Stepper` or `TextField`. For an object from the environment, you can create one locally inside `body`: `@Bindable var cart = cart`.
- - **Navigate with values.** `NavigationLink(value:)` plus `navigationDestination(for:)` means the destination is built when it is needed, from a `Hashable` value. Passing an ID and looking it up in the shared model keeps a single source of truth.
- ### Fine-grained tracking and nested objects
- With `ObservableObject`, a parent publishes a change only when its own `@Published` property is assigned a new value. If `items` is an array of objects and you change `items[3].quantity`, the array still holds the same references, so the parent publishes nothing and the list does not update. This is the classic "array not updated" problem.
- Observation tracks changes per property, on each object. The row that reads `item.quantity` depends on that property of that item, so changing it updates the row. You no longer need to forward nested changes by hand.
- ## Pitfalls
- ### The @State initial value, and Xcode 27
- This one deserves care, because the behavior changed in 2026.
- Up to Xcode 26, Apple's documentation for `State` says the property *always instantiates its default value when SwiftUI instantiates the view*. SwiftUI keeps only the first instance as storage, but `Model()` is evaluated again every time the parent re-creates the view struct, and that can happen often. `@StateObject` never had this problem, because it takes its initial value as an autoclosure. That made `@State private var model = ExpensiveModel()` a subtle performance and side-effect trap.
- With Xcode 27, `@State` is a Swift macro instead of a property wrapper. Apple's documentation and technote TN3211 say the initial value is now evaluated lazily, only once, when the view is first created. The behavior depends on the Xcode version you build with, so a team still on Xcode 26 has the old behavior. TN3211 also lists source incompatibilities. One of them: if a `@State` property has an inline default value and you also assign it in `init`, the inline value wins and your assignment is discarded.
- My rules, which work with both toolchains:
- - Keep `@State` initial values cheap and free of side effects.
- - Do expensive work such as network calls, file access or database setup in `.task` (iOS 15+), not in initializers.
- - When a model depends on a parameter, create it in `.task(id:)`, so a new parameter value actually produces a new model:
- ```swift
- @MainActor
- @Observable
- final class ProfileModel {
- let userID: Int
- var name = ""
- init(userID: Int) { self.userID = userID }
- func load() async { /* fetch the profile */ }
- }
- struct ProfileScreen: View {
- let userID: Int
- @State private var model: ProfileModel?
- var body: some View {
- Group {
- if let model { ProfileContent(model: model) } else { ProgressView() }
- }
- .task(id: userID) {
- let model = ProfileModel(userID: userID)
- self.model = model
- await model.load()
- }
- }
- }
- ```
- That last point is the key to models that depend on a parameter: state is created once per view identity. Passing a different parameter later does not replace it, unless you change the identity (with `.id(userID)`) or rebuild the model yourself, as above.
- ### Other traps
- - **`@State` for objects you do not own.** If a parent passes a model in, the child must not wrap it in `@State`. That is the old `@StateObject` vs `@ObservedObject` confusion with new names.
- - **Environment without an owner.** The non-optional `@Environment(Cart.self)` assumes some ancestor called `.environment(cart)`. If none did, you find out at runtime, not at compile time. Previews are the usual place this happens. If a missing object is a legitimate case, declare the property as `Cart?`; Apple documents that the optional form returns `nil`.
- - **Mixing both worlds.** An `ObservableObject` stored in `@State` only updates the view when the reference changes, not when its `@Published` properties change. Migrate a model completely.
- ## Summary
- 1. UIKit forward: initializer injection, with `instantiateViewController(identifier:creator:)` (iOS 13+) for storyboards.
- 2. UIKit backward: a closure for one result, a `weak` delegate for many events.
- 3. SwiftUI, iOS 17+: `@Observable` + `@State` at the owner, `.environment` / `@Environment(Type.self)` to share, `@Bindable` only where you need bindings.
- 4. Navigate with values through `NavigationStack` and `navigationDestination(for:)` (iOS 16+).
- 5. Keep `@State` initial values cheap, move heavy setup to `.task` or `.task(id:)`, and remember that Xcode 27 changed when that initial value is evaluated.
- Moving data between screens is mostly a question of who owns it. Once that is clear, the right API is usually obvious.