Home / Blog / SwiftUI state: when to reach for @State, @Observable, or @Environment

SwiftUI state: when to reach for @State, @Observable, or @Environment

SwiftUI's state APIs feel tangled. Here's a practical decision tree worth the two minutes it'll save you on picking the right one.

State management in SwiftUI has shifted over the years. @Observable arrived in iOS 17, @StateObject in iOS 14, and at the start there was only @State. Working with junior iOS devs, this is the topic that confuses them most: which property wrapper fits which situation?

Here’s the decision tree I walk through at the start of every new project.

Question 1: Does this data live in a single view?

It isn’t shared with other views, isn’t persisted, isn’t updated from somewhere else. It lives inside one view. A user flips a toggle, types in a text field, picks a tab.

Answer: @State.

Simple, performant, correct. Don’t overthink it.

Question 2: Is the data shared between two or more views?

You’re passing data from parent to child, the child mutates it, and you want the parent to see the change. Or sibling views share a common state.

Answer: @State in the parent, @Binding in the child. The $ prefix creates a binding, think of it as a pointer to the value rather than the value itself.

Question 3: Does the data live in a business-logic class?

A view-independent ViewModel, a data loader, an authentication manager. It has to be a class (not a struct) because identity matters.

On iOS 17+: @Observable + @State.
iOS 16 and below: ObservableObject + @Published properties + @StateObject.

The @Observable macro opts every property into the observation system automatically, no @Published required. It’s measurably more performant, too: only the properties that are actually read invalidate the view.

If you target iOS 17+, forget @StateObject and @ObservedObject. @Observable + @State (for ownership) is enough.

Question 4: Is this object shared across the whole app?

User session, theme, auth manager, global settings. You want the same instance wherever you are.

Answer: @Environment. Inject it on the parent with .environment(session), pick it up in any nested child with @Environment(AppSession.self) var session.

Environment solves prop drilling. Even five levels deep you can reach the session.

Question 5: Does it need to persist to disk?

User settings, last-opened date, subscription state, anything you want to keep across relaunches.

For small things: @AppStorage (a UserDefaults wrapper). One-liner for String, Int, Bool, Date.

For larger data (lists, user models): CoreData, SwiftData, or JSON on disk.

The common confusion: @State vs @Binding

These two trip up newcomers more than anything else.

  • @State: “I create and own this data”
  • @Binding: “I use this data and I report changes back to the parent”

A view uses @State only for data it creates itself. To mutate data someone else gave it, it uses @Binding.

Antipattern: a new @ObservedObject instance on every render

The most common mistake I saw on iOS 16 and earlier: writing @ObservedObject var viewModel = UserViewModel() in a view body. Every re-render creates a new UserViewModel. Animations break, data vanishes.

The correct pattern is @StateObject so the view owns the instance. On iOS 17+, @Observable + @State solves this cleanly.

Takeaway

A simple decision tree:

  • View-local data → @State
  • Parent-child sharing → @Binding
  • Business-logic class → @Observable + @State (iOS 17+)
  • App-wide state → @Environment
  • Small persistent data → @AppStorage

Those five patterns cover 95% of SwiftUI. The remaining 5% you pick up when you need to.

Have a project on this topic?

Leave a brief summary — I’ll get back to you within 24 hours.

Get in touch