State management in SwiftUI in Swift, SwiftUI & iOS Apps
Getting state management wrong in SwiftUI leads to views that don't update when they should, data that vanishes unexpectedly, or performance nightmares from excessive re-renders. State management in SwiftUI in Swift, SwiftUI & iOS Apps isn't magic—it's about understanding where each piece of data lives and ensuring changes flow through your view hierarchy predictably.
Property wrappers — Pick by ownership
SwiftUI gives us several property wrappers, each designed for a specific ownership pattern. Choosing the right one prevents bugs and keeps your code maintainable.
@State: Local view state
@State owns data directly in a view. When state changes, SwiftUI re-renders that view and its children. Use @State for data that belongs entirely to one view—toggles, text input, animation progress, or temporary UI state.
struct LoginForm: View {
@State private var email = ""
@State private var password = ""
@State private var isLoading = false
var body: some View {
VStack {
TextField("Email", text: $email)
SecureField("Password", text: $password)
if isLoading {
ProgressView()
} else {
Button("Sign In") {
isLoading = true
Task {
await authenticate(email: email, password: password)
isLoading = false
}
}
}
}
}
}The private keyword matters—it signals that this state is internal to the view and shouldn't be accessed from outside.
@Binding: Two-way access to parent state
@Binding creates a two-way reference to state owned elsewhere, typically in a parent view. When a child modifies a binding, the parent updates. Use bindings when a child view needs to edit parent state without owning it.
struct ParentView: View {
@State private var selectedTab = 0
var body: some View {
VStack {
TabSelector(selection: $selectedTab)
ContentView(tabIndex: selectedTab)
}
}
}
struct TabSelector: View {
@Binding var selection: Int
var body: some View {
Picker("Tab", selection: $selection) {
Text("Home").tag(0)
Text("Search").tag(1)
Text("Profile").tag(2)
}
.pickerStyle(.segmented)
}
}Notice the $ prefix in the parent—that converts the state into a binding passed to the child. The child receives it as @Binding and can modify it directly.
@Observable model: Shared app or screen state
For data that multiple views need to observe—user session, cart contents, filter state—use @Observable (iOS 17+). Mark your model class with @Observable, and SwiftUI automatically tracks which properties each view accesses.
import Observation
@Observable
class CartModel {
var items: [CartItem] = []
var total: Double = 0
func addItem(_ item: CartItem) {
items.append(item)
updateTotal()
}
func removeItem(_ id: String) {
items.removeAll { $0.id == id }
updateTotal()
}
private func updateTotal() {
total = items.reduce(0) { $0 + $1.price }
}
}
struct ProductDetailView: View {
@State private var cart = CartModel()
var body: some View {
VStack {
Button("Add to cart") {
cart.addItem(product)
}
NavigationLink("View cart") {
CartView()
}
}
.environment(cart)
}
}
struct CartView: View {
@Environment(CartModel.self) var cart
var body: some View {
List(cart.items) { item in
HStack {
Text(item.name)
Spacer()
Text("$\(item.price, format: .currency(code: "USD"))")
}
}
.navigationTitle("Cart (\(cart.items.count))")
}
}The magic: only views that read cart.items re-render when items change. A view reading only cart.total stays stable. This fine-grained reactivity prevents unnecessary re-renders.
@Environment: Values passed down the tree
@Environment accesses values from the view hierarchy without explicitly passing them through every intermediate view. Use it for app-wide settings, theme, or accessibility preferences.
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.preferredColorScheme(nil)
.environment(\.locale, Locale(identifier: "de_DE"))
}
}
}
struct SettingsView: View {
@Environment(\.locale) var locale
@Environment(\.colorScheme) var colorScheme
var body: some View {
Text("Current locale: \(locale.identifier)")
Text("Color scheme: \(colorScheme == .dark ? "Dark" : "Light")")
}
}SwiftUI's built-in environments (locale, colorScheme, dismiss) are always available. For custom data, create an environment key:
private struct AnalyticsKey: EnvironmentKey {
static let defaultValue = Analytics()
}
extension EnvironmentValues {
var analytics: Analytics {
get { self[AnalyticsKey.self] }
set { self[AnalyticsKey.self] = newValue }
}
}
// Usage
struct ContentView: View {
@Environment(\.analytics) var analytics
var body: some View {
Button("Log event") {
analytics.track("button_tapped")
}
}
}Single source of truth
Each piece of data should be owned in exactly one place. If multiple views need the same data, they should read it from a single owner, not maintain separate copies.
This prevents conflicts and bugs. Imagine a user list where one view owns a copy of users and another view owns a different copy. One updates from the server; the other doesn't. You've created a sync nightmare.
Instead:
- One model owns the data (
@Observableor@Statein a parent). - Child views receive a binding or read from environment.
- Updates flow through one path.
// ✅ Good: Single source of truth
@Observable
class UserListModel {
var users: [User] = []
var isLoading = false
func loadUsers() async {
isLoading = true
users = await fetchUsers()
isLoading = false
}
}
struct UserListScreen: View {
@State private var model = UserListModel()
var body: some View {
if model.isLoading {
ProgressView()
} else {
List(model.users) { user in
UserRow(user: user)
}
}
.task {
await model.loadUsers()
}
}
}
// ❌ Bad: Multiple owners
struct UserListScreenBad: View {
@State private var users: [User] = []
var body: some View {
List(users) { user in
UserRowBad(userID: user.id) // This might load its own copy
}
}
}
struct UserRowBad: View {
let userID: String
@State private var user: User? // Separate owner
}Observation framework — @Observable replaces ObservableObject in iOS 17+
If you're supporting iOS 17 and later, migrate from ObservableObject and @Published to @Observable. The new framework is simpler, more efficient, and requires no Combine imports.
// ❌ Old approach (still valid for iOS 16)
class OldUserModel: ObservableObject {
@Published var name = ""
@Published var email = ""
@Published var isVerified = false
func updateProfile(_ newName: String) {
name = newName
}
}
// ✅ New approach (iOS 17+)
@Observable
class UserModel {
var name = ""
var email = ""
var isVerified = false
func updateProfile(_ newName: String) {
name = newName
}
}
// Usage is nearly identical but simpler
struct ProfileView: View {
@State private var userModel = UserModel()
var body: some View {
TextField("Name", text: $userModel.name)
}
}@Observable automatically tracks property access. Views only re-render if they access properties that changed, making performance tuning easier. No need to manually publish selected properties.
Keep views simple — Move logic into models
Views should describe UI, not business logic. Network calls, calculations, validation, and state mutations belong in model classes. This keeps views testable and reusable.
// ❌ Logic in view
struct CheckoutViewBad: View {
@State private var cart: [CartItem] = []
@State private var isProcessing = false
var body: some View {
Button("Complete purchase") {
isProcessing = true
Task {
do {
let total = cart.reduce(0) { $0 + $1.price }
let taxRate = 0.08
let tax = total * taxRate
let finalTotal = total + tax
let response = try await URLSession.shared.data(
from: URL(string: "https://api.example.com/checkout")!
)
isProcessing = false
} catch {
isProcessing = false
}
}
}
}
}
// ✅ Logic in model
@Observable
class CheckoutModel {
var cart: [CartItem] = []
var isProcessing = false
var total: Double {
cart.reduce(0) { $0 + $1.price }
}
var tax: Double {
total * 0.08
}
var finalTotal: Double {
total + tax
}
func completeCheckout() async throws {
isProcessing = true
defer { isProcessing = false }
let request = CheckoutRequest(total: finalTotal)
_ = try await APIClient.checkout(request)
}
}
struct CheckoutView: View {
@State private var checkoutModel = CheckoutModel()
var body: some View {
Button("Complete purchase") {
Task {
try await checkoutModel.completeCheckout()
}
}
}
}This separation makes testing straightforward—test the model without rendering views—and makes the view's intent crystal clear at a glance.
Common mistakes
- Using @State for shared data. If multiple views need the same value, don't give each one
@State. Move it to a shared@Observablemodel or parent state. - Forgetting
privateon @State. External views shouldn't access a sibling's@State. Always mark itprivate. - Creating circular references. If Model A holds a reference to Model B, and Model B holds a reference to Model A (via environment), you can create retain cycles. Keep your dependency graph acyclic.
- Storing view-only state in a persisted model. Don't bloat your
@Observablemodels with UI ephemera like keyboard focus or animation state. Keep those in@Statenear the view that uses them. - Mixing bindings with environment. Pick one pattern per piece of state. Passing both a binding and environment reference to the same data confuses ownership.
Key takeaways
- Use
@Statefor view-local data,@Bindingfor parent state edits,@Observablefor shared models, and@Environmentfor values passed down the tree. - Each value should have exactly one owner. Children read or bind to it; they don't duplicate it.
- Migrate to
@Observableon iOS 17+; it's simpler and more efficient thanObservableObject. - Keep logic out of views. Models handle calculations, validation, and side effects. Views describe UI.
- Fine-grained reactivity in SwiftUI means views re-render only when the data they read changes. Structure state to leverage this.






Comments
Loading comments…