New SwiftUI State Management

An Updated Comprehensive Guide (2025)

YLabZ
23 min readFeb 23, 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

Much Simpler SwiftUI State Management

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

Decision Tree for old SwiftUI State Management

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.
New Decision Tree for SwiftUI State Management
Hope this makes it clear!

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 as let or var 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): @ObservedObjectno 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 in body – 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

Decision Tree

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 for ObservableObject 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!

--

--

Responses (3)