New SwiftUI State Management
An Updated Comprehensive Guide (2025)
Summary
@State
manages local, mutable state that belongs to the view. It now supports wrapped values, simplifying state management for both value and reference types.
@Bindable
enables automatic bindings to properties of an observable object, making it easier to pass state from parent to child and create two-way bindings in UI controls. Use it for external observable objects that require mutation within the view.
@Environment
remains the primary method for injecting external values throughout the view hierarchy, ensuring shared state is accessible where needed.
Summary for updating your Code
Quick summary of how to update your SwiftUI state management code to iOS 17+ using the new Observation model.
1. Convert ObservableObject
to @Observable
Before (iOS 16):
class PostsViewModel: ObservableObject {
@Published var posts: [Post] = []
}
After (iOS 17+):
@Observable class PostsViewModel {
var posts: [Post] = [] // No more @Published needed
}
✅ SwiftUI now tracks all properties automatically.
2. Replace @StateObject
with @State
(if the view owns the model)
Before (iOS 16):
struct PostsView: View {
@StateObject private var viewModel = PostsViewModel()
}
After (iOS 17+):
struct PostsView: View {
@State private var viewModel = PostsViewModel()
}
✅ SwiftUI ensures the object is preserved across re-renders.
3. Remove @ObservedObject
for read-only child views
Before (iOS 16):
struct DisplayView: View {
@ObservedObject var settings: AppSettings
}
After (iOS 17+):
struct DisplayView: View {
var settings: AppSettings // No wrapper needed
}
✅ SwiftUI automatically observes changes to @Observable
models.
4. Use @Bindable
for child views needing two-way binding
Before (iOS 16):
struct SettingsView: View {
@ObservedObject var settings: AppSettings
}
After (iOS 17+):
struct SettingsView: View {
@Bindable var settings: AppSettings
}
✅ Allows $settings.property
for direct binding in UI controls.
5. Replace @EnvironmentObject
with @Environment(Type.self)
Before (iOS 16):
struct PostsListView: View {
@EnvironmentObject var viewModel: PostsViewModel
}
After (iOS 17+):
struct PostsListView: View {
@Environment(PostsViewModel.self) private var viewModel
}
✅ Use .environment(viewModel)
in parent view to inject it.
Summary of Changes
Key Takeaways
✅ Less boilerplate (no more @Published
, @StateObject
, or @ObservedObject
).
✅ More efficient updates (SwiftUI tracks state changes more precisely).
✅ Easier to follow (just use @State
, @Environment
, and @Bindable
where needed).
✅ Backward-compatible (you can mix old and new approaches while transitioning).
Next Steps
🔹 Start by updating your models (@Observable
).
🔹 Then migrate views to use @State
, @Environment
, and @Bindable
.
🔹 Test your app and ensure everything updates correctly.
This update simplifies state management and improves performance. 🚀
Let me know if you any questions 😊
👇🏽 Below are all the complete details 👍🏽
Exploring SwiftUI’s New State Management:
SwiftUI has always been a declarative framework that emphasizes a clear separation between your data and its presentation. With the latest updates, Apple has streamlined state management in SwiftUI, making it simpler and more intuitive to work with both value and reference types. This article explores the changes, explains the new property wrappers, and provides practical examples to help you modernize your SwiftUI apps.
A Brief History: Then and Now
The Old Approach
Previously, SwiftUI developers had a handful of property wrappers to manage state:
- @State: For value types like integers, strings, or even structs.
- @StateObject: For owning reference types conforming to
ObservableObject
. - @ObservedObject: For observing externally provided reference types.
- @Binding: For passing state down the view hierarchy.
This model worked well, but it sometimes led to confusion about which wrapper to use for a given scenario, especially when dealing with reference types.
The New Unified Model
Summary of Key Choices:
@State
→ If the view owns the data, use@State
. (e.g., counter values, form inputs).@Environment
→ If the data is provided globally, use@Environment
. (e.g., app settings, shared models).@Bindable
→ If you need to bind UI controls to properties of an external observable object, use@Bindable
.- Plain property → If the view is passed an object and only needs to read/observe it, no property wrapper is needed.
Apple’s latest guidance has unified these concepts by introducing a more streamlined approach:
step-by-step guide on updating SwiftUI code from an older version (iOS 16 or earlier) that uses @StateObject
, @ObservedObject
, and @EnvironmentObject
to the new SwiftUI state model introduced in iOS 17+.
This will include transitioning from ObservableObject
to the new @Observable
macro, updating property wrappers, and adjusting state management patterns to align with Apple's latest guidance.
Updating SwiftUI State to iOS 17’s Observation Model (Details)
iOS 17 introduced a new SwiftUI state management model (the Observation framework) that replaces many of the patterns used in iOS 16 and earlier. The new approach uses the @Observable
macro for model objects and updates how we use property wrappers like @State
, @Environment
, and a new @Bindable
wrapper in views.
Below is a step-by-step guide to migrating your SwiftUI code to Apple’s latest recommended patterns.
Step 1: Convert ObservableObject
Classes to @Observable
Begin by updating your data model classes. In iOS 16, models often conformed to ObservableObject
and used @Published
for properties. In iOS 17, mark these classes with the @Observable
macro and remove any @Published
wrappers – all stored properties will be auto-tracked. This makes the class conform to the new Observation.Observable
protocol behind the scenes, so SwiftUI can automatically observe its changes.
Before (iOS 16 and earlier): Using ObservableObject
and @Published
:
import SwiftUI
class PostsViewModel: ObservableObject {
@Published var posts: [Post] = [] // published property
// ... other properties and methods
}
After (iOS 17+): Using the @Observable
macro (no ObservableObject
or @Published
):
import SwiftUI
import Observation // import required for @Observable in some contexts
@Observable
class PostsViewModel {
var posts: [Post] = [] // automatically observable
// ... other properties and methods
}
What changed: We removed the ObservableObject
conformance and the @Published
attribute. Simply adding @Observable
to the class generates the code needed to track its properties. Any changes to posts
will now automatically notify SwiftUI views that use this object. (If there are properties you don’t want to trigger view updates, mark them with @ObservationIgnored
inside the class.)
Step 2: Replace @StateObject
with @State
for Owned Models
For view models that a view owns/creates (previously using @StateObject
), you should now use @State
in the view. This might seem counterintuitive, since we used to reserve @State
for simple value types, but it is the intended approach in the new model. The @State
wrapper will cache the @Observable
object instance so it isn’t recreated on every view update. Importantly, @State
still acts as the “source of truth” to trigger view updates when the observable’s properties change.
Before: A view owning a model uses @StateObject
:
struct PostsView: View {
@StateObject private var viewModel = PostsViewModel() // iOS 16 style
var body: some View {
// ... use viewModel.posts, etc.
}
}
After: Use @State
for the @Observable
model:
struct PostsView: View {
@State private var viewModel = PostsViewModel() // iOS 17 style
var body: some View {
// ... use viewModel.posts, etc.
}
}
Now viewModel
is an @Observable
object stored in state. Any changes to its published properties (like posts
) will cause the view to update. You still use @State
to maintain a single source of truth for the model instance across view refreshes. This replaces @StateObject
in almost all cases.
Note: You do not need
@ObservedObject
on this property. Marking it with@State
is enough for SwiftUI to observe it because the object itself is @Observable
Step 3: Update @ObservedObject
Usage in Child Views
When passing an observable model to child views, the property wrappers you use will change depending on how the child needs to use the data:
- Read-only usage: If the child view only needs to read data from the model (no mutation), you can remove the
@ObservedObject
wrapper entirely. Just declare a plain property for the model. SwiftUI will automatically track changes to any@Observable
properties that the view’s body reads, so the child will still update when the model changes. You can mark it aslet
orvar
depending on whether the parent might replace the instance. - Two-way binding (mutation) usage: If the child view needs to modify the model’s state or bind UI controls to it, use the new
@Bindable
property wrapper.@Bindable
allows the child to create bindings to the model’s properties (using the$
prefix). This replaces the pattern of using@ObservedObject
together with$object.property
bindings from earlier SwiftUI.
Before: Child view using @ObservedObject
(reading and editing via binding):
struct SettingsView: View {
@ObservedObject var settings: AppSettings // AppSettings is ObservableObject with @Published
var body: some View {
Toggle("Hide Titles", isOn: $settings.hidesTitles)
}
}
After (read-only case): Child view that only reads from an @Observable
model:
struct DisplayView: View {
var settings: AppSettings // no wrapper, just a passed-in @Observable object
var body: some View {
Text("Titles are \(settings.hidesTitles ? "Hidden" : "Shown")")
}
}
After (binding case): Child view that needs to bind to the model’s properties:
struct SettingsView: View {
@Bindable var settings: AppSettings // @Observable object with two-way binding
var body: some View {
Toggle("Hide Titles", isOn: $settings.hidesTitles) // $settings.property now works
}
}
In the read-only case, simply referencing settings.hidesTitles
in the body
is enough for SwiftUI to track it – no explicit wrapper is needed. In the binding case, @Bindable
lets us use $settings.hidesTitles
to get a Binding<Bool>
for the toggle. Under the hood, @Bindable
creates a binding to the instance’s properties, not to the instance itself, which is why it’s used when you need two-way updates.
Step 4: Use @Environment
Instead of @EnvironmentObject
The mechanism for sharing an observable object via the view hierarchy (previously done with @EnvironmentObject
) has been streamlined. Now you can inject an @Observable
object into the environment with the standard environment(_:)
modifier and retrieve it with the @Environment
property wrapper. The key difference is that you must specify the object’s type when reading it from the environment.
Before: Injecting and consuming an environment object (iOS 16 style):
// Parent view sets up the environment object
let model = PostsViewModel()
ParentView().environmentObject(model)
// Child view reads the environment object
struct ChildView: View {
@EnvironmentObject var viewModel: PostsViewModel
/* ... uses viewModel ... */
}
After: Using environment(_:)
with an @Observable
and @Environment
:
Parent View: Injecting @Observable
into the Environment —
import SwiftUI
import Observation // Required for @Observable
@Observable
class PostsViewModel {
var posts: [String] = ["Post 1", "Post 2"]
}
struct ParentView: View {
private var viewModel = PostsViewModel() // No @State (since it's global)
var body: some View {
PostsListView()
.environment(viewModel) // Inject into environment
}
}
Child View: Reading @
Environment Data
struct PostsListView: View {
@Environment(PostsViewModel.self) private var viewModel // Correct way to retrieve
var body: some View {
List(viewModel.posts, id: \.self) { post in
Text(post)
}
}
}
In the parent, we call .environment(viewModel)
instead of .environmentObject(viewModel)
. In the child, we use @Environment(PostViewModel.self)
to retrieve it, rather than @EnvironmentObject
. SwiftUI will still ensure that if viewModel
changes (or any of its observable properties change), the child views update. This new approach is more type-safe and doesn’t require pre-declaring an EnvironmentObject
dependency. Just make sure to provide the object in the view hierarchy with .environment(_:)
and use the correct type in @Environment(...)
when consuming it.
Best Practices with the New Observation Model
When adopting the new SwiftUI state model, keep these best practices in mind to align with Apple’s recommendations:
- Use the new wrappers consistently: Replace old property wrappers with the new equivalents summarized below. Apple essentially recommends moving off
@ObservedObject
,@StateObject
, and@EnvironmentObject
in favor of the new patterns. - Model classes:
ObservableObject
→@Observable
(remove@Published
properties). - Owned model in View:
@StateObject
→@State
(to keep a single source-of-truth instance). - Child view (read-only):
@ObservedObject
→ no wrapper (just pass the object, SwiftUI observes it). - Child view (needs binding):
@ObservedObject
→@Bindable
(for two-way bindings). - Environment:
@EnvironmentObject
→@Environment(MyType.self)
(with.environment(obj)
modifier). - Preserve object identity with
@State
: Even though an@Observable
object will notify views of changes, you should still use@State
(or keep it in the environment) to store the instance if the view creates it. This ensures the object isn’t reinitialized every time the view updates. In short,@State
now also covers the use case of keeping an observed reference type alive, which was previously the role of@StateObject
. - Take advantage of automatic tracking: You no longer need to mark every usage with special wrappers. SwiftUI will re-render a view only when properties it uses have changed, avoiding unnecessary updates. This is more efficient than the older
@Published
approach, which would refresh the view on any change even if the specific property wasn’t used. Embrace this by simply reading your observable’s properties inbody
– SwiftUI handles the rest. - Adopt incrementally if needed: You don’t have to rewrite everything at once. The new Observation API is designed to work alongside the old one during a transition. For example, you can start by marking your models with
@Observable
and still use them with@StateObject
or@EnvironmentObject
in some views for compatibility. SwiftUI’s backward compatibility means an@Observable
object will still function with the old wrappers, so you can update your app step by step. - Test and refine: After migrating, test your app thoroughly. Ensure that views update as expected when the model changes. The new model should reduce redundant updates and make state handling more predictable. If something isn’t updating, confirm you’ve either stored the object in
@State
or passed it correctly via@Environment
so that SwiftUI knows about it.
By following these steps and practices, your SwiftUI code will be aligned with Apple’s latest state management recommendations for iOS 17 and later. Adopting the Observation framework simplifies your code (less boilerplate) and often improves performance by fine-grained tracking of state changes. This transition will future-proof your SwiftUI app and ensure you’re making the most of the framework’s evolution. Good luck with your migration!
Deep Dive into the New Property Wrappers
Local State with @State
@State
is the primary property wrapper for local, view-owned state. Use @State
when a piece of data is tightly coupled to a single view and you want changes to that data to trigger a UI update in that view. SwiftUI manages the storage and lifecycle of @State
properties for you. When the state value changes, SwiftUI knows to redraw the view (and any dependent subviews) to reflect the new value.
What kinds of data should be @State
? Think of simple value types or other small pieces of state that belong to the view. This could be a Bool
for a toggle, a String
for the text in a TextField, an index for a picker selection, or any other value that the view can modify. Even if the state is more complex (for example, a struct or an object reference), if this view owns it and it's not used elsewhere, @State
is appropriate. In modern SwiftUI, even reference-type models can be stored in @State
as long as those models are made observable (using @Observable
on their class) – this allows their internal changes to be tracked and update the UI. (In older versions, you might have been told not to put reference types in @State
, but with the new observation mechanism, an observable class in @State
will still trigger view updates when its properties change.)
Example — A simple counter:
struct CounterView: View {
@State private var count: Int = 0 // local state owned by this view
var body: some View {
VStack {
Text("Count: \(count)")
Button("Increment") {
count += 1 // modifying @State triggers UI update
}
}
}
}
In this example, the count
property is marked with @State
because it’s purely local to CounterView
. Tapping the button increments the count, and SwiftUI will re-render the Text with the new count value. Under the hood, SwiftUI stores count
separately from the view struct and monitors it – when it changes, the view’s body
is invalidated and recomputed.
Example — Initializing an observable object as state: Consider we have a model class for a new item a user is creating:
@Observable class Item {
var name: String = ""
var quantity: Int = 1
}
We want to use this in a view (for example, a form to add a new item). With the observation model, we can do:
struct NewItemForm: View {
@State private var draftItem = Item() // an @Observable object as local state
var body: some View {
Form {
TextField("Name", text: $draftItem.name)
Stepper("Quantity: \(draftItem.quantity)", value: $draftItem.quantity, in: 1...100)
Button("Save") {
// use draftItem for something, then perhaps reset it
}
}
}
}
Here, draftItem
is an instance of an @Observable
class stored in @State
. SwiftUI will manage the lifetime of this object along with the view (it’s created when the view appears and can be discarded when the view is removed). Because Item
is marked @Observable
, changes to its properties (name
or quantity
) that the view’s body reads will automatically trigger the view to update. This pattern replaces the older need for @StateObject
in many cases – previously we might have used @StateObject var draftItem = Item()
to keep an ObservableObject alive, but now simply using @State
with an observable class achieves the same goal with less boilerplate.
Best practice: Use @State
for encapsulated state that doesn’t need to be accessed outside the view. It’s lightweight and SwiftUI’s preferred way to manage mutable values that belong to a view. Remember that @State
should not be abused for passing data between views; it’s strictly for a view’s own use. If you find yourself wanting to share an @State
value with another view, that’s a sign to use bindings or elevate the state to a higher scope (as we’ll discuss next).
(Note: The misnamed “@Observed
” is sometimes mistakenly used in tutorials, but there is no @Observed
wrapper in SwiftUI. The correct wrapper was @ObservedObject
for external observable objects. With modern SwiftUI, however, you will typically use @State
(if the view owns the object) or other wrappers described below instead of @ObservedObject
.)
Shared App State with @Environment
For globally accessible or shared state in SwiftUI, the @Environment
property wrapper is the tool to use. The environment is a way to inject values from a parent or ancestor view (often set at the App or scene level) down into the view hierarchy, without passing them explicitly through every initializer. This is ideal for app-wide settings, user preferences, or shared data models that many views need to read (and possibly write).
Using @Environment
for an observable object replaces the older @EnvironmentObject
wrapper. In fact, with the new observation APIs, @EnvironmentObject
is essentially folded into @Environment
– you just specify the type of the data to retrieve it from the environment. SwiftUI will provide the correct observable object if it was inserted into the environment upstream. This simplifies usage and avoids another separate wrapper.
Providing an environment object: To make a model available via @Environment
, you must inject it into the environment hierarchy. Typically, this is done in a parent view or the App struct. For example, suppose we have a user settings model:
@Observable class Settings {
var isDarkMode: Bool = false
// ... other settings
}
We can set it up in the App entry point:
@main
struct MyApp: App {
@State private var settings = Settings() // app-wide observable object
var body: some Scene {
WindowGroup {
ContentView()
.environment(Settings.self, settings) // inject into environment by type
}
}
}
Here we create a single instance of Settings
and store it (as @State
, since the App is also a kind of view) so that it lives for the app’s lifetime. We then inject it with .environment(Settings.self, settings)
so any view can grab it using @Environment(Settings.self)
.
Using @Environment
in a view: Now any descendant view can access this Settings
object:
struct ContentView: View {
@Environment(Settings.self) private var settings // retrieve the shared Settings
var body: some View {
VStack {
Text("Dark mode is \(settings.isDarkMode ? "On" : "Off")")
Button("Toggle Dark Mode") {
settings.isDarkMode.toggle()
}
}
}
}
By declaring @Environment(Settings.self) var settings
, we ask SwiftUI to fetch the Settings
instance of that type from the environment. Because Settings
is @Observable
, when the isDarkMode
property changes, any view that reads settings.isDarkMode
will update automatically. In the example above, tapping the button toggles the setting and the text updates accordingly. We didn’t need to mark settings
with any special wrapper beyond @Environment
– SwiftUI knows it’s an observable object from the environment and will track its changes.
Environment values: Note that @Environment
is also used for built-in environment values like color scheme, accessibility settings, etc. For example, you might use @Environment(\\.\colorScheme) var colorScheme
to read the current color scheme. This works similarly, but uses keys defined by SwiftUI. In all cases, @Environment
“lets values be propagated as globally accessible” to many views. Use it when the data logically belongs at a higher level (like user settings, app model, or global app state) rather than within a single view.
Replacing @EnvironmentObject
: In older SwiftUI, you would mark a view’s property with @EnvironmentObject
to get a shared object and rely on the environment to supply it. Now, you simply use @Environment(MyType.self)
. Aside from the syntax change, the concept is the same: the view expects the object to exist in the environment. Just be sure to inject the object using the .environment(_:_:)
modifier, or your app will crash if the environment object is missing. The new approach is slightly more explicit (tying the lookup to a type), but it reduces the number of special property wrappers we need to remember.
When to use @Environment
: Use it for dependencies or state that many parts of your UI share, or for data that naturally lives above the view in the hierarchy (for example, app state provided at the top). If only one view or a small view subtree needs the data, consider passing it in directly or using @State
in a parent. But if the data truly has a broader scope (e.g. user profile, theme settings, a shared data controller), environment is a clean solution. It allows any child view to easily access the data without cumbersome parameter threading.
Passing Data to Child Views with @Binding
Often, you want to pass a piece of state from a parent view to a child view, especially if the child needs to modify that state (for example, a custom control that operates on a value owned by the parent). In SwiftUI, the pattern for this is using bindings. A binding is essentially a read-write reference to a value that lives somewhere else (the “source of truth”).
The parent view, which owns the state (likely an @State
property), will pass a projected binding to the child using the $
prefix. The child view will receive it as an @Binding
property. This way, the child can read and update the value, and those changes propagate back to the parent’s state.
Example — Parent and child using a binding:
struct ParentView: View {
@State private var playerName: String = "Alice" // source of truth in parent
var body: some View {
VStack {
Text("Player: \(playerName)")
ChildView(name: $playerName) // pass binding to child
}
}
}
struct ChildView: View {
@Binding var name: String // child gets a binding to parent's state
var body: some View {
TextField("Enter name", text: $name)
.textFieldStyle(.roundedBorder)
.padding()
}
}
In ParentView
, playerName
is a @State
string. We pass $playerName
into ChildView
. In ChildView
, the name
property is marked with @Binding
, indicating it doesn’t have its own storage – it’s a portal to some external value. The TextField in ChildView
is bound to $name
, so editing the text updates playerName
back in the parent. This is the classic way to share a single value between views in SwiftUI.
Use @Binding
whenever you have a single value that one view owns and another view needs to mutate or reflect. It keeps a single source of truth (the state in the parent) while allowing controlled access in the child. Many SwiftUI controls use bindings (e.g. toggles, pickers, sliders) because they operate on outside state.
@Binding
in SwiftUI has not changed with the new observation model – it’s still an essential tool. In fact, Apple clarifies the difference in purpose between @Binding
and the new @Bindable
:
“Binding is used for reading and writing a value owned by a source of truth, whereas Bindable is used only to create bindings from the properties of an observable object.” — Apple Docs
We’ll explore @Bindable
next.
Working with Observable Objects via @Bindable
When a view is dealing with an observable object that comes from elsewhere (not owned by this view) and you need to mutate or bind to its properties, SwiftUI provides the @Bindable
property wrapper. This is a new wrapper introduced with the Observation framework. It’s designed to make it easy to get two-way bindings to properties of an @Observable
object that a view either receives from its parent or accesses from the environment.
In the past, if a view had an external @ObservedObject
, you could not directly use the $
syntax on that object’s properties in the view’s body unless that object was wrapped properly. The new @Bindable
solves that problem in a clean way. Apple calls @Bindable
“really lightweight – all it does is allow bindings to be created from that type”.
By marking a view’s property with @Bindable
, you unlock the ability to use the $
prefix to get bindings to any observable property of that object inside your view.
When to use @Bindable
: Use it if your view is given an observable object instance (for example, via an initializer or from @Environment
) and you want to edit that object’s properties with controls in this view. If the view only needs to read the object’s data (without providing editable inputs), you might not need @Bindable
– just using a plain property or @Environment
to get the object is enough to observe changes. But if you have, say, a form that should edit fields of an object provided by a parent, @Bindable
is the key to avoid a lot of boilerplate.
Example — Editing an observable model passed from a parent:
Imagine a parent view that holds a user profile object and wants to present an editor for it:
@Observable class Profile {
var username: String = ""
var prefersNotifications: Bool = true
}
The parent view might have:
struct SettingsView: View {
@State private var profile = Profile() // or this could come from environment
var body: some View {
ProfileEditor(profile: profile)
}
}
Here, profile
is an @Observable
object (either created as state or could be from environment). We pass it into ProfileEditor
. In the child view, we use @Bindable
:
struct ProfileEditor: View {
@Bindable var profile: Profile // make the passed-in Profile bindable
var body: some View {
Form {
TextField("Username", text: $profile.username)
Toggle("Enable Notifications", isOn: $profile.prefersNotifications)
}
}
}
By declaring @Bindable var profile
, ProfileEditor
gains the ability to write $profile.username
and $profile.prefersNotifications
inside its body. The text field and toggle are now directly bound to the Profile
object’s properties. Any changes the user makes (e.g. toggling notifications off) will update the profile
object, and since Profile
is observable, those changes also propagate to any other views using the same object. This is exactly what we want for a shared model being edited.
Behind the scenes, @Bindable
works because Profile
is an @Observable
class. The wrapper doesn’t hold its own value; it simply exposes the binding interface.
Paul Hudson explains it like this: “If you’ve been passed an object without any bindings – an object you know is
@Observable
– then you can use@Bindable
to create bindings for you.”
It’s analogous to the role @ObservedObject
played previously in allowing property bindings, but now it’s more explicit and only for binding purposes
A few things to note about @Bindable
:
- It only applies to observable objects (classes), not value types. If you have a struct or a simple value, you should use
@Binding
instead for two-way binding. (In other words,@Bindable
is the class-based complement to@Binding
- You typically use
@Bindable
on a view’s property (as shown above). In cases where the object comes from@Environment
and you cannot mark the property with@Bindable
directly (because you already used@Environment
on it), there’s a workaround: you can create a temporary bindable reference inside your view’s body or initializer.
For example:
import SwiftUI
import Observation
@Observable
class Settings {
var isDarkMode: Bool = false
}
struct ContentView: View {
@Environment(Settings.self) private var settings // Inject settings
var body: some View {
Toggle("Dark Mode", isOn: Binding(
get: { settings.isDarkMode },
set: { settings.isDarkMode = $0 }
))
.padding()
}
}
@main
struct MyApp: App {
@State private var settings = Settings() // App-wide settings
var body: some Scene {
WindowGroup {
ContentView()
.environment(settings) // Provide settings to environment
}
}
}
SwiftUI may offer some conveniences for this pattern, but the simplest is often to design your data flow such that a view that needs to edit an object gets it passed in directly (so you can mark it @Bindable
), or passes a binding for specific properties. It’s an advanced scenario, but keep in mind that @Bindable
cannot be applied twice on the same property.
You do not use @Bindable
for objects that the view itself owns as @State
. If a view creates an @Observable
object using @State
, SwiftUI already gives you bindings to its properties without needing @Bindable
. For instance, in our earlier NewItemForm
example, $draftItem.name
works even though draftItem
was just @State
, because SwiftUI knows to provide bindings for an observable state object. @Bindable
is only needed when the object wasn’t created by this view’s state.
Best Practices and Summary
Use the simplest property wrapper that fits your needs. With the 2025-recommended approach, that usually means one of @State
, @Environment
, or @Bindable
for most cases. Here’s a summary of how to choose:
@State
– Use for local state that belongs to a single view. It’s the primary wrapper for any mutable value in a view. This ensures SwiftUI can manage and persist that state across view updates. Example: the selected tab index in a view, the text in a search field, or an@Observable
model instance used only within this view. Always initialize it with a value. SwiftUI will destroy this state when the view is removed, so don’t store long-lived data here.@Environment
– Use for shared or global state that is provided by a parent (often at app startup or higher up the view tree). This is great for app settings, user session data, theme info, or anything many views might need. Inject the value using.environment()
on an ancestor, and retrieve it with@Environment
in children. The environment value can be an@Observable
object, which means any view reading its properties will update when they change. Keep in mind environment data should be available for the lifetime of those views (for example, the app supplies it). Also, prefer environment for truly global concerns; if only a couple of views share something, passing it via view initializers might be simpler.@Binding
– Use for parent-child communication of a single value. This is the go-to for letting a child view mutate some state owned by the parent. It avoids separate copies of state and ensures both views refer to the same source. Common in custom controls or when breaking down a view into subcomponents that need to write back to parent state.@Bindable
– Use for editing an observable object passed into a view. This wrapper shines when you have a data model (class) that multiple views might use, and you want to modify it in this view via forms or controls. Marking the property@Bindable
gives you direct bindings to its fields. This replaces the old pattern of using@ObservedObject
plus manual binding logic. Remember that the object should be an@Observable
class (or similar) so that SwiftUI knows how to watch its properties. If the view only reads from the object,@Environment
(for globally provided objects) or a plain property (for injected ones) is enough – no need for@Bindable
unless you need the$
access for controls.- No “
@Observed
” or misuse of wrappers – Be precise with SwiftUI’s property wrappers. There is no@Observed
in SwiftUI; the correct legacy wrapper was@ObservedObject
, but with the new system you will rarely use it. Instead of@ObservedObject
, adopt one of the above strategies (environment or bindable or state) depending on ownership. Similarly,@StateObject
is now less common – if you find yourself wanting to use it, consider if the object can be made an@Observable
and stored in@State
or provided from outside. The new model has eliminated the need forObservableObject
protocol,@Published
, and their associated view wrappers in most scenarios, resulting in simpler code. However, all the old property wrappers still work for compatibility. You can migrate incrementally: a mix of old and new patterns is possible, but it’s best to standardize on the new approach for clarity.
In modern SwiftUI, these patterns ensure you have a single source of truth for each piece of data and that the UI stays in sync with the underlying model. The SwiftUI framework will take care of observing changes and updating the interface, as long as you’ve used the proper property wrappers. Always ask yourself: “Who owns this data, and who needs to use or change it?” Then apply the appropriate wrapper or passing mechanism. By following these guidelines, you’ll avoid common pitfalls (like missing updates or redundant state) and embrace the streamlined data flow Apple now recommends.
SwiftUI’s state management is powerful yet simple when used correctly. With @State
, @Environment
, and @Bindable
in your toolbox – and an understanding of the new observation model – you can build UIs that are both responsive to data changes and clear in design. Happy Swifting!