All articles
iOS20 min read

Mastering SwiftUI State Management in 2025: @State, @Binding, @ObservedObject, @StateObject — Plus…

Confused between @State, @Binding, @ObservedObject, @StateObject, @Observable and @Bindable in SwiftUI? You’re not alone. This 2025 guide…

K
Karan Pal
Author
Image generated by AI
Image generated by AI

🚀 Introduction: Why State Management Is the Backbone of SwiftUI

If you’re building apps with SwiftUI and find yourself muttering things like “Why isn’t my view updating?” or “Why is this toggle broken again?!” — you’re not alone 😅

Welcome to the wild (but surprisingly elegant) world of SwiftUI state management.

Unlike UIKit, where you imperatively tell the UI what to do, SwiftUI flips the script. It says,

“Hey developer, just give me the data and I’ll figure out how the UI should look.”

This means your UI is a reflection of your data. And to make that work like magic, SwiftUI gives us tools like @State, @Binding, and @ObservedObject.

But here’s the twist:

Each of them looks similar but behaves very differently. Use the wrong one, and your UI might not update, or worse, you’ll be playing hide-and-seek with bugs you didn’t invite 🐛

In this guide, we’ll break down each of these three state tools with real-world examples, use cases, and a few gotchas along the way — so by the end, you’ll know what to use when, and why it matters. No PhD in reactive programming required 🧠✨

This 2025 guide includes both the traditional property wrappers (@State, @ObservedObject, etc.) and the modern Observation framework (@Observable, @Bindable) introduced in iOS 17 back in 2023. Whether you’re maintaining older apps or building fresh on iOS 17+, this guide has you covered

Ready? Let’s start with the humble @State.

🧠 Understanding State in SwiftUI

Before we dive into @State, @Binding, and the rest of the crew, let’s zoom out a bit.

In SwiftUI, state is the single source of truth that drives how your UI looks and behaves. If the state changes, the UI changes. If the state doesn’t change, SwiftUI just chills and leaves your pixels untouched 😎

Think of it like this:

🥤 Your app is a vending machine. 🧃 Your state is the drink inside. 🧑 The user presses a button (interacts with the view), and you update the drink (state). 🖥️ SwiftUI immediately changes the display to show the new drink.

No need to manually refresh the label or update constraints. SwiftUI watches your state like a hawk and re-renders the affected views when something changes.

But here’s the catch:

Not all state is created equal. Some state is local and should be kept private. Some needs to be shared with child views. And some needs to be managed in a view model.

That’s where our three main tools come in:

Next up, we’ll meet the simplest (but most common) of them all: @State.

🧱 @State: The Basics for Local View State

If SwiftUI were a band, @State would be the lead singer — always front and center, especially in beginner tutorials.

🧐 What is @State?

@State is a property wrapper that lets a view own and mutate a piece of data. When the data changes, SwiftUI automatically re-renders the view to reflect the change. Simple, clean, and elegant.

But there’s one rule:

@State is meant to be private. Only the view that declares it should modify it. It’s like a secret stash of UI logic tucked safely away in its own view.

🔧 When Should You Use It?

✅ Code Example: Toggling a Dark Mode

struct DarkModeToggleView: View {
    @State private var isDarkMode = false

    var body: some View {
        VStack {
            Text(isDarkMode ? "🌙 Dark Mode" : "☀️ Light Mode")
                .font(.largeTitle)
                .padding()

            Toggle("Enable Dark Mode", isOn: $isDarkMode)
                .padding()
        }
    }
}

Every time the user flips the toggle, the isDarkMode state changes, and SwiftUI says:

“Ah! The state changed. Time to update the view!”

Unlike UIKit, you don’t have to call reloadData() or setNeedsLayout() — SwiftUI handles the heavy lifting 🚀

⚠️ Gotcha: Don’t Share @State Around

You cannot pass a @State variable directly to another view. It’s meant to be private.

Want to let a child view read/write the same data? That’s where @Binding enters the scene (coming up next 👇).

🔗 @Binding: Passing State Between Views

Sometimes, your view has some state (managed using @State) that you want to share with a child view — but without giving up control. Think of @Binding as a two-way mirror 🪞: the child can see and change the value, but the actual data still lives in the parent.

🤝 What is @Binding?

@Binding lets a child view read and write a value that’s owned by its parent view. It doesn’t own the state — it’s just borrowing it with permission.

The key idea:

The parent uses _@State_, the child uses _@Binding_.

📦 When Should You Use It?

✅ Code Example: Toggle Controlled by Parent

Let’s reuse our dark mode idea, but split it into parent-child:

struct ParentView: View {
    @State private var isDarkMode = false

    var body: some View {
        ChildToggleView(isDarkMode: $isDarkMode)
    }
}

struct ChildToggleView: View {
    @Binding var isDarkMode: Bool

    var body: some View {
        Toggle("Enable Dark Mode", isOn: $isDarkMode)
            .padding()
    }
}

Here’s what’s happening:

Boom — both views stay in sync without any extra work.

⚠️ Gotcha: Bindings Need a Source

You cannot initialize a **@Binding** variable on its own. SwiftUI will yell at you.

@Binding var someValue: Int = 0// ❌ You can't set this inside the view

It must be passed in from another view that owns the state.

🧠 @ObservedObject: Managing Shared, Complex State

When your app grows beyond a few toggles and text fields, you’ll want to separate logic from views. That’s where @ObservedObject shines — it’s perfect for managing shared, class-based data across multiple views.

Think of it like your app’s state manager with superpowers.

🧪 What is @ObservedObject?

@ObservedObject is a property wrapper that lets your SwiftUI view observe a reference type (class) that conforms to ObservableObject. Any changes inside that object will trigger view updates, as long as the changed properties are marked with @Published.

In other words:

It’s how SwiftUI views listen to changes in an external class and update themselves accordingly 📻

🔍 When Should You Use It?

✅ Code Example: ViewModel with Counter

class CounterViewModel: ObservableObject {
    @Published var count = 0
    
    func increment() {
        count += 1
    }
}

struct CounterView: View {
    @ObservedObject var viewModel: CounterViewModel

    var body: some View {
        VStack(spacing: 16) {
            Text("Count: \(viewModel.count)")
                .font(.title)

            Button("Increase") {
                viewModel.increment()
            }
        }
        .padding()
    }
}

Usage in a parent view:

struct ContentView: View {
    var body: some View {
        CounterView(viewModel: CounterViewModel())
    }
}

⚠️ Gotcha: Forgetting @Published = Silent Fails

If you forget to add **@Published** to a property, changes to it won’t trigger UI updates. You’ll be tapping your button in frustration while SwiftUI stares blankly 😐

Also, note: recreating the object on every render (like CounterViewModel() inside .body) will reset your state. Always keep your view model outside the body, or use @StateObject (we’ll cover that in future chapters).

🎁 Bonus: Wait, What About @StateObject?

So far, we’ve been juggling @State, @Binding, and @ObservedObject. But hold up — there’s a fourth sibling peeking from the shadows: @StateObject.

It’s here to solve one of the most common bugs in SwiftUI apps.

🚨 The Problem It Solves

Remember how we said recreating a view model with @ObservedObject inside a view can reset its state every time the view redraws?

struct MyView: View {
    var body: some View {
        CounterView(viewModel: CounterViewModel()) // ⚠️ Bad! Resets every time
    }
}

That’s where @StateObject steps in like:

“Let me own and initialize the object. I’ll keep it alive across re-renders.” 🦸‍♂️

🔧 When to Use @StateObject

✅ Example: The Right Way to Own a ViewModel

class TimerViewModel: ObservableObject {
    @Published var seconds = 0
}

struct TimerView: View {
    @StateObject private var viewModel = TimerViewModel()

    var body: some View {
        Text("Seconds: \(viewModel.seconds)")
    }
}

No more resets. The TimerViewModel is created once, owned by the view, and survives redraws like a champ 💪

⚖️ @ObservedObject vs @StateObject

Mixing them up is the #1 way to create invisible bugs in SwiftUI — the kind that make you doubt reality 😵‍💫

🤯 What Happens If You Use @ObservedObject Instead of @StateObject

If you write this:

struct MyView: View {
    @ObservedObject var viewModel = MyViewModel() // 🚨

    var body: some View {
        Text("Hello \(viewModel.name)")
    }
}

SwiftUI will compile it (no red flags), but here’s what goes wrong:

💣 The Bug (Without Crashing)

Every time SwiftUI re-renders MyView, it recreates the viewModel from scratch.

That means:

And worst of all? There are no error messages 😵‍💫 Just mysterious behavior.

🧠 Why It Happens

✅ The Fix: Use @StateObject Instead

struct MyView: View {
    @StateObject private var viewModel = MyViewModel()

    var body: some View {
        Text("Hello \(viewModel.name)")
    }
}

📌 Summary: When to Use What

🚀 @Observable and @Bindable (iOS 17+): The Future of State Management

Welcome to the cool kids’ club — say hello to @Observable and @Bindable, the dynamic duo that entered the SwiftUI scene with iOS 17 (Swift 5.9). They’re here to clean up your ObservableObject + @Published mess and make your state code feel ✨fresh and modern✨.

🧠 What is @Observable?

**@Observable** is a new macro-based property wrapper that marks your class or struct as observable by SwiftUI. It automatically tracks which properties change and updates the UI when needed — no more @Published, no more ObservableObject, and no more forgetting to make your types final.

🧪 Example:

@Observable
class UserSettings {
    var username: String = ""
    var isLoggedIn: Bool = false
}

That’s it. No @Published, no ObservableObject, no fuss. Swift handles all the magic behind the scenes using the new Observation framework.

🔗 What is @Bindable?

If @Observable is the brains, @Bindable is the connector. It allows you to create bindings to specific properties inside your observable models — just like @Binding, but more powerful and precise.

You use it in a view when you want two-way binding with an @Observable object’s properties, especially useful in forms and input fields.

🧪 Example:

struct ProfileView: View {
    @Bindable var user: UserSettings

    var body: some View {
        Form {
            TextField("Username", text: $user.username)
            Toggle("Logged In", isOn: $user.isLoggedIn)
        }
    }
}

⚔️ How is This Different from the Old Way?

✅ What @Observable replaces:

✅ What @Bindable replaces:

🆚 Old vs New (The TL;DR)

Before (iOS 13–16):

Now (iOS 17+):

You might be wondering — isn’t _$user.name_ something we already did with _@ObservedObject_? Technically yes, but the behavior is quite different. With the old system, _$user.name_ relied on dynamic member lookup and _@Published_ magic to dig into your _ObservableObject_. It looked like a direct binding, but it was really a clever workaround.
With _@Bindable_ and the new Observation framework, _$user.name_ is the real deal — a true binding to the name property inside your _@Observable_ model. No more _@Published_, no hacks, and no runtime reflection. It’s faster, more precise, and feels exactly how SwiftUI bindings were always meant to work. 🔥

🚦 When Should You Use It?

If your app supports iOS 17+, there’s almost no reason not to use the new system. It’s cleaner, more efficient, and easier to reason about. But if you’re still supporting iOS 16 or earlier, you’ll have to stick with the classic approach.

📦 Bonus Tip: You Can Use @Observable in Structs Too!

Yup, unlike ObservableObject which only worked with classes, @Observable supports structs as well — making it a great fit for SwiftData models and lighter view models.

📝 This section exists thanks to a thoughtful reader’s (_Ken Turnbull_) comment:
“Your code uses the older ObservableObject and StateObject rather than the Observation Protocol which uses @Observable. My question was simply… how would all this code look if one was using @Observable and not ObservableObject?”
That’s a fantastic point — and something I didn’t initially include. So let’s clear that up.
Below is the updated version of the same logic, rewritten using the _modern @Observable + @Bindable approach_ introduced in iOS 17 (Swift 5.9). You’ll notice how much cleaner it is — no need for @Published, no need for ObservableObject, and no dynamic member lookup tricks. Just pure SwiftUI elegance. ✨

Embedded content

🧭 When to Use What (The No-Nonsense Guide)

By now, you’ve met the trio (and their overachieving cousin @StateObject) — but how do you decide which one to use and when?

Let’s simplify it with a set of “If this, then that” rules:

🟢 Use @State when:

🧠 Think: “Private little secret that only this view needs to know.”

🔵 Use @Binding when:

🧠 Think: “Here, borrow my state — but give it back when you’re done.”

🟣 Use @ObservedObject when:

🧠 Think: “I’ll watch it, but someone else is in charge of keeping it alive.”

🟠 Use @StateObject when:

🧠 Think: “This is my baby. I’ll raise it and make sure it stays alive.”

🧪 Use @Observable when:

✅ (iOS 17+ only)

🔗 Use @Bindable when:

✅ (iOS 17+ only)

Still confused which one to use? If your app state is:

🧪 Real-World Mini Project: Toggle, Counter & Shared ViewModel

Let’s say you’re building a simple productivity app with:

Yep — we’re using all four in one smooth, coordinated dance 💃🕺

🔨 Step 1: ViewModel with Shared State

class TaskViewModel: ObservableObject {
    @Published var taskCount = 0

    func addTask() {
        taskCount += 1
    }

    func resetTasks() {
        taskCount = 0
    }
}

🔧 Step 2: Child View with a Toggle Using @Binding

struct FocusToggleView: View {
    @Binding var isFocusModeOn: Bool

    var body: some View {
        Toggle("Focus Mode", isOn: $isFocusModeOn)
            .padding()
    }
}

🧩 Step 3: Child View with counter using @ObservedObject

struct TaskStatsView: View {
    @ObservedObject var viewModel: TaskViewModel   // @ObservedObject used here

    var body: some View {
        VStack {
            Text("📊 Task Stats")
                .font(.headline)
            
            Text("You've completed \(viewModel.taskCount) tasks.")
                .foregroundColor(.secondary)
        }
    }
}

🧱 Step 4: Main View with All State Types

struct DashboardView: View {
    @State private var isFocusModeOn = false                      // @State
    @StateObject private var viewModel = TaskViewModel()          // @StateObject

    var body: some View {
        VStack(spacing: 20) {
            FocusToggleView(isFocusModeOn: $isFocusModeOn)        // @Binding

            Text("Tasks Completed: \(viewModel.taskCount)")
                .font(.title)

            Button("Add Task") {
                viewModel.addTask()
            }

            Button("Reset") {
                viewModel.resetTasks()
            }

            if isFocusModeOn {
                Text("🔕 Notifications are silenced")
                    .foregroundColor(.gray)
            }


            Divider()

            TaskStatsView(viewModel: viewModel) 
        }
        .padding()
    }
}

🧠 Why This Works

This kind of small-but-complete setup is something you’ll see often in real-world SwiftUI apps — and knowing which state tool to use where keeps your code clean, efficient, and bug-free.

🐛 Common Mistakes & Debugging Tips

SwiftUI’s state system is powerful… until you accidentally wire it wrong and your UI turns into a stubborn toddler — refusing to update, randomly resetting, or just plain ignoring you 😤

Here are the most common mistakes (and how to fix them):

❌ 1. Forgetting @Published in Your ObservableObject

“My _@ObservedObject_ isn’t triggering updates!”

Why it happens:

You created a class conforming to ObservableObject, but forgot to mark its properties with @Published.

class MyViewModel: ObservableObject {
    var count = 0 // ← No @Published = no UI update 😐
}

Fix it:

@Published var count = 0 // ✅ View updates when this changes

❌ 2. Creating the ViewModel in .body or as @ObservedObject

“Why does my state reset every time?”

If you write:

struct MyView: View {
    @ObservedObject var viewModel = MyViewModel() // 😱
}

SwiftUI will recreate this object every render, resetting your state. Silent, sneaky, and brutal.

Fix it:

Use @StateObject instead so the view owns and retains the instance:

@StateObject private var viewModel = MyViewModel() // ✅

❌ 3. Using @State When You Should Use @ObservedObject

“Why can’t I access my view model’s properties?”

If you try something like this:

@State var viewModel = MyViewModel() // ❌ Doesn’t observe changes

SwiftUI won’t observe changes inside your class because @State is for value types, not reference types.

Fix it: Use @StateObject or @ObservedObject depending on ownership.

❌ 4. Trying to Initialize a @Binding Without a Source

“Why is SwiftUI yelling at me about _@Binding_?”

This fails:

@Binding var isEnabled: Bool

init() {
    self._isEnabled = Binding.constant(true) // 😵‍💫 Usually not what you want
}

Fix it: Always pass @Binding from a parent view that owns the original @State.

Or, for previews/testing, use .constant(true) intentionally:

#Preview {
    FocusToggleView(isFocusModeOn: .constant(true)) // ✅
}

🧠 Debugging Tip: Use print() or onChange

SwiftUI may be “magical,” but print(“State changed!”) still goes a long way when things don’t feel right.

You can also track changes reactively:

.onChange(of: viewModel.taskCount) { newValue in
    print("Task count is now \(newValue)")
}

And that’s it — you’ve officially leveled up your SwiftUI state skills ⚔️

🏁 Final Recap: What You Learned

SwiftUI’s state system can feel like a maze at first, but once you understand which tool to use and where — it becomes a superpower 💪

Here’s your cheat sheet for quick recall:

And remember:

Choose the right tool, and SwiftUI works like magic. Choose the wrong one… and SwiftUI gaslights you into thinking nothing is wrong 😅

You’ve now got the knowledge to build cleaner, smarter, and more predictable SwiftUI apps — and you can finally stop guessing which “@” to slap on your properties.

Next: Navigation in SwiftUI (2025 Edition)

Coming up in the next article of the SwiftUI Mastery Series, we’ll dive into:

You’ll never get lost in a navigation flow again 🧭

**SwiftUI Navigation: NavigationStack, Deep Linking, and TabView Explained** _🧭 This guide demystifies SwiftUI’s navigation system — from TabView setups to deep linking and advanced…_medium.comhttps://medium.com/swift-pal/swiftui-navigation-navigationstack-deep-linking-and-tabview-explained-0f905bbb20d4

📺 Prefer video? This article is also available as a comprehensive video tutorial

Embedded content

🎉 Enjoyed this article? Your support means the world to me!

💬 Drop a comment below! I love hearing about your experiences and answering questions

🎬 Subscribe on Youtube and become early subscribers of my channel: https://www.youtube.com/@swift-pal

💼 Let’s connect on LinkedIn for more professional insights: https://www.linkedin.com/in/karan-pal

Happy coding! 🚀

● The newsletter

New articles, straight to your inbox.

No spam, no filler — just new writing on iOS, the web, and AI when it ships. Unsubscribe anytime.

Keep reading