DEV Community

Sebastien Lato
Sebastien Lato

Posted on

State Management in SwiftUI: The Complete Guide

State management is the core of SwiftUI.

It decides:

  • how your UI updates
  • how your logic flows
  • how modules communicate
  • how async tasks reflect in the UI
  • how stable and predictable your app feels

But most apps mix state incorrectly — leading to:

  • random UI glitches
  • infinite re-renders
  • ViewModel recreation
  • broken bindings
  • stale values
  • async cancellation issues

This is the complete guide to mastering state management in SwiftUI — covering both classic tools and the modern @Observable model.


🔥 1. The Four Primary State Tools in SwiftUI

SwiftUI gives you four main property wrappers:

Wrapper Lifetime Usage
@State Local to the view Simple UI values
@Binding Mirrors parent state Passing state downward
@StateObject Persistent across view reloads ViewModels
@ObservedObject Not persistent For “temporary” hosting of models

Plus two global tools:

  • @Environment
  • @observable (new in 2023+, future-default in 2026)

Let’s break down when to use each.


🟦 2. @State — For Local, Ephemeral UI Values

Use when the value:

  • belongs only to this view
  • resets when the view disappears
  • is simple (Bool, Int, small struct)

Example:

struct LoginView: View {
    @State private var email = ""
    @State private var password = ""
    @State private var loading = false
}
Enter fullscreen mode Exit fullscreen mode

Never store heavy objects here.


🟧 3. @Binding — Passing State Downward

Use when:

  • child views need to modify parent state
  • two-way interaction is required
Slider(value: $volume)
Enter fullscreen mode Exit fullscreen mode

Bindings should never be stored long-term in ViewModels.


🟩 4. @StateObject — The Correct Place for ViewModels

Only use @StateObject for long-lived objects that:

  • manage business logic
  • handle async work
  • must not be recreated on UI refresh
  • own derived or computed state

Example:

@StateObject var viewModel = FeedViewModel()
Enter fullscreen mode Exit fullscreen mode

This preserves identity across:

  • animations
  • view reloads
  • tab switches
  • parent state updates

Rule:
If recreating the object breaks your feature → it should be a @StateObject.


🟥 5. @ObservedObject — Use Sparingly

@ObservedObject should be used when:

  • the view does not own the object
  • the parent provides it
  • it's fine for the object to be recreated

It is not for ViewModels, except in rare hosted cases.


🟪 6. @Environment — Global Read-Only State

Use @Environment for:

  • system values
  • externally provided configuration
  • app-level services (if registered properly)
  • theme / device metrics
  • error presenters
  • routing containers

Example:

@Environment(ErrorPresenter.self) var errors
Enter fullscreen mode Exit fullscreen mode

Do NOT store large mutable objects here unless you're intentionally building an AppState container.


🟧 7. @observable — The Future of SwiftUI State

@observable replaces ObservableObject + @Published.

Example:

@Observable
class FeedViewModel {
    var posts: [Post] = []
    var loading = false
}
Enter fullscreen mode Exit fullscreen mode

No @Published.
No objectWillChange.
No boilerplate.

It dramatically reduces:

  • invalidations
  • unpredictable updates
  • object identity bugs

Recommendation:
Use @observable for all new ViewModels unless you need KVO or compatibility with older code.


🔄 8. Understanding SwiftUI’s Render Cycle (What Actually Triggers Updates)

A view re-renders when:

✔ a @State value changes
✔ an @observable property changes
✔ a @StateObject publishes a change
✔ a Binding value changes
✔ a parent’s body changes

A view does not re-render when:

✘ unrelated @State properties change
✘ async tasks finish but state is unchanged
✘ external services update without binding

Understanding this prevents 90% of performance bugs.


🧱 9. State Ownership Rules (The Architecture Part)

Use this simple checklist:

✔ UI owns UI state
(@State)

✔ View owns its ViewModel
(@StateObject)

✔ ViewModel owns business logic
(@observable)

✔ Services own side effects
(networking, storage, sync)

✔ AppState owns global shared data
(user session, cache, settings)

Incorrect:
Views owning services.
ViewModels containing views.
Models storing closures.
Bindings leaking upward.


🧩 10. Derived & Computed State (Avoid Heavy Work)

A common anti-pattern:

var filtered = posts.filter { ... }  // computed every refresh
Enter fullscreen mode Exit fullscreen mode

Instead:

  • compute inside the ViewModel
  • memoize expensive transforms
  • store the derived result
@Observable
class FeedViewModel {
    var posts: [Post] = []
    var search = ""

    var filtered: [Post] {
        posts.filter { $0.title.contains(search) }
    }
}
Enter fullscreen mode Exit fullscreen mode

SwiftUI only recomputes when dependent data changes.


🧵 11. Handling Async State Safely

Async tasks must bind state to the main actor:

@MainActor
func load() async {
    loading = true
    defer { loading = false }

    do {
        posts = try await api.fetch()
    } catch {
        errors.present(map(error))
    }
}
Enter fullscreen mode Exit fullscreen mode

This prevents:

  • UI updates from background threads
  • race conditions
  • stale state
  • invalid mutations

📡 12. Avoiding the Biggest State Bug: Identity Loss

This happens when SwiftUI recreates a ViewModel accidentally:

FeedView(viewModel: FeedViewModel()) // ❌ recreated every render
Enter fullscreen mode Exit fullscreen mode

Solutions:

@StateObject var viewModel = FeedViewModel()     // Best for view-owned VM

FeedView(viewModel: existingVM)                 // Best for parent-controlled
Enter fullscreen mode Exit fullscreen mode

or for navigation:

.navigationDestination(item: $selected) { item in
    DetailView(itemID: item.id)
}
Enter fullscreen mode Exit fullscreen mode

🧭 13. Global State (AppState Containers)

For shared app-wide state:

@Observable
class AppState {
    var session: UserSession?
    var tabSelection = 0
    var pendingDeepLink: AppRoute?
}
Enter fullscreen mode Exit fullscreen mode

Injected into environment:

.environment(appState)
Enter fullscreen mode Exit fullscreen mode

This enables:

  • shared navigation
  • session flows
  • offline behavior
  • deep linking

But be careful — global state is powerful and should be minimized.


⚡ 14. Performance Rules

✔ Keep @State small
✔ Prefer @observable over @Published
✔ Push expensive work out of views
✔ Memoize derived state
✔ Keep ViewModels reference types
✔ Do not store large models in @State
✔ Use .task instead of .onAppear for async work

These patterns keep your UI responsive.


🚀 Final Thoughts

State management is the foundation of great SwiftUI architecture.
Mastering it unlocks:

  • predictable UI
  • smooth async flows
  • stable navigation
  • reusable modules
  • scalable architecture
  • simpler ViewModels

This guide gives you the full modern toolkit used in production SwiftUI apps.

Top comments (0)