SwiftUI has four property wrappers for state and most code I review uses the wrong one. Views re-render when nothing changed. Or worse, state silently resets when the view rebuilds and the user loses their work.
The fix is not memorizing the API. It is understanding which wrapper owns the source of truth and which ones just observe it. Once that clicks, the rest is obvious.
The mental model
There are two axes:
- Who owns the state? The view that creates it owns it. Everyone else borrows.
- How granular is the update? Does a view re-render on any change, or only when a property it actually reads changes?
Map every wrapper onto those two axes and you stop guessing.
@State — local, owned, value type
@State is for primitive values owned by a single view. Toggles, text field text, animation flags. SwiftUI manages storage so it survives re-renders.
struct ToggleRow: View {
@State private var isOn = false
var body: some View {
Toggle("Notifications", isOn: $isOn)
}
}The trap: putting shared state in @State. If a parent needs to read it, it is no longer local. Move it up.
@Binding — borrowed write access
When a child needs to mutate state owned by a parent, pass a binding.
struct Parent: View {
@State private var volume: Double = 0.5
var body: some View {
VolumeSlider(volume: $volume)
}
}
struct VolumeSlider: View {
@Binding var volume: Double
var body: some View {
Slider(value: $volume, in: 0...1)
}
}The child does not own the value. It just writes to it. The parent stays the source of truth.
@Observable — shared model objects (iOS 17+)
This replaces ObservableObject. The macro tracks which properties each view reads and only re-renders those views when those properties change.
@Observable
final class SessionStore {
var user: User?
var cart: [CartItem] = []
var isLoading = false
private let api: APIClient
init(api: APIClient) {
self.api = api
}
func loadUser() async {
isLoading = true
defer { isLoading = false }
user = try? await api.fetchUser()
}
}Inject it through the environment so any view can read it:
@main
struct MyApp: App {
@State private var session = SessionStore(api: APIClient())
var body: some Scene {
WindowGroup {
RootView()
.environment(session)
}
}
}
struct ProfileHeader: View {
@Environment(SessionStore.self) private var session
var body: some View {
if let user = session.user {
Text(user.name)
}
}
}The view above only re-renders when session.user changes. If session.cart updates, this view does nothing. That is the property-level tracking working for you.
@Environment — dependency injection, not global state
The environment is for dependencies. API clients, auth stores, theme, settings. Two rules:
- If only one subtree needs it, pass it as a regular initializer argument.
- If many unrelated views need it, put it in the environment.
The environment is not a dumping ground for everything. Resist the urge to stuff every piece of state there because it is convenient.
@Bindable — bindings into observable models
When a child view needs bindings into an @Observable model, use @Bindable:
struct EditProfileForm: View {
@Bindable var session: SessionStore
var body: some View {
TextField("Name", text: $session.user.name)
}
}This restores the $ syntax for observable models. Without it, you cannot pass a binding to a text field.
The trap I see constantly
Code like this:
struct CartBadge: View {
@State private var cartCount = 0 // wrong
var body: some View {
Text("\(cartCount)")
.onReceive(cartPublisher) { cartCount = $0.count }
}
}The badge listens to a publisher and writes to @State. It works for a while. Then the parent rebuilds, the badge re-initializes, and cartCount resets to zero for one frame before the publisher fires again. The badge flickers.
The fix is to make the cart a shared @Observable model. The badge reads from it. No publisher, no flicker, no race.
When you still need ObservableObject
Three cases:
- You support iOS 16 and earlier.
- You have a
NSManagedObjector Core Data entity (use@FetchRequestinstead). - You are integrating a library that exposes its own
ObservableObjecttypes.
Otherwise, @Observable is the better default for new code in 2026.
How I structure a real app
Three layers:
- Models —
@Observableclasses holding domain data and business logic - Views — pure functions of model state, no business logic
- Environment — the models, injected at the app root
If I cannot answer "who owns this state?" in one sentence, I have a structural problem, not a property-wrapper problem. Fix the ownership first, then pick the wrapper.
State management in SwiftUI gets easier when you stop thinking about wrappers and start thinking about ownership. The wrappers fall out of the answer.
Need help untangling your SwiftUI app?
I have rescued enough tangled SwiftUI codebases to know where the bodies are buried. Let's talk.
Frequently Asked Questions
What is the difference between @State and @Binding in SwiftUI?
@State owns the value. @Binding is a reference to state owned elsewhere. Use @State in the parent that creates the value, pass it with the $ prefix to children, and accept it as @Binding in the child.
Should I use ObservableObject or @Observable in 2026?
Use @Observable for new code targeting iOS 17 and later. It does property-level dependency tracking, so a view re-renders only when a property it reads changes. ObservableObject re-renders the entire view on any published change.
When should I use @Environment in SwiftUI?
Use @Environment for dependencies and shared configuration like API clients, authentication stores, themes, and locale. Avoid using it as a shortcut for data the view tree can pass down directly through initializers.
Why does my SwiftUI view lose its state on re-render?
You are likely using @State for shared data, or storing state in a struct that gets recreated. Move long-lived state into an @Observable model, or use @StateObject for ObservableObject conformance. @State inside a view struct is only safe for truly local values.