Introduction
Ever had your SwiftUI app crash mid‑navigation with that dreaded EXC_BAD_ACCESS? I remember the first time it hit me mid‑night, a new feature, and a stack trace that read:
Thread 1: EXC_BAD_ACCESS (code=1, address=0x0000000000)It wasn’t a typo, it wasn’t a missing import. It was the state object getting released while a view was still trying to read it. In this post I’ll walk through why that happens when you nest NavigationViews, how to reproduce it, and, most importantly, how to stop it.
What is EXC_BAD_ACCESS?
EXC_BAD_ACCESS is a low‑level crash that tells the runtime you tried to access a memory address that’s no longer valid. In Swift, it often surfaces when you hold a reference to an object that has already been deallocated. The typical error message you’ll see in Xcode:
Thread 1: EXC_BAD_ACCESS (code=1, address=0x0000000000)It’s the equivalent of dereferencing a null pointer in C. The crash is silent in the sense that the app just dies, but the stack trace usually points to a line in your SwiftUI view where a property is accessed.
@StateObject Basics
@StateObject is SwiftUI’s way of owning an observable object. You create it once, and SwiftUI keeps it alive for the lifetime of the view hierarchy that owns it.
Wrong
struct ProfileView: View {
@StateObject var viewModel = ProfileViewModel() // ❌
var body: some View {
Text(viewModel.name)
}
}If ProfileView is recreated multiple times (e.g., inside a NavigationLink), SwiftUI will re‑initialize viewModel each time, potentially releasing the old instance. That’s a recipe for EXC_BAD_ACCESS if another view still references the old object.
Right
class ProfileViewModel: ObservableObject {
@Published var name = "John Doe"
}
struct ProfileView: View {
@StateObject private var viewModel = ProfileViewModel()
var body: some View {
Text(viewModel.name)
}
}Here, @StateObject is marked private and the view owns a single instance. SwiftUI guarantees it stays alive as long as the view is in the hierarchy.
Tip: Never initialize a
@StateObjectinside a computed property or a function that can be called multiple times.
Nested NavigationViews and Their Pitfalls
When you place a NavigationView inside another NavigationView, you create two independent navigation stacks. If each stack owns its own @StateObject, they can get out of sync, and a view might try to read a state object that has already been deallocated.
Wrong
struct RootView: View {
var body: some View {
NavigationView {
NavigationLink("Go to Details", destination: DetailView())
}
}
}
struct DetailView: View {
@StateObject var detailVM = DetailViewModel()
var body: some View {
NavigationView { // ❌ nested
Text(detailVM.info)
}
}
}The inner NavigationView creates a new navigation stack. If the user goes back to RootView and the outer stack is popped, detailVM may be released while the inner navigation stack still holds a reference to it.
Right
struct RootView: View {
var body: some View {
NavigationView {
NavigationLink("Go to Details", destination: DetailView())
}
}
}
struct DetailView: View {
@StateObject private var detailVM = DetailViewModel()
var body: some View {
VStack {
Text(detailVM.info)
NavigationLink("Next", destination: NextView())
}
}
}By removing the nested NavigationView, the entire navigation stack is shared, and the @StateObject stays alive as long as the user is on DetailView or any of its children.
Reproducing the Crash
Let’s build a minimal example that crashes on a back navigation.
class CounterVM: ObservableObject {
@Published var count = 0
}
struct CounterView: View {
@StateObject var vm = CounterVM()
var body: some View {
NavigationView {
VStack {
Text("Count: \(vm.count)")
Button("Increment") { vm.count += 1 }
NavigationLink("Next", destination: NextView())
}
}
}
}
struct NextView: View {
@StateObject var vm = CounterVM() // new instance
var body: some View { Text("Next") }
}Run this, hit “Increment” a few times, tap “Next”, then press back. The app crashes with:
Thread 1: EXC_BAD_ACCESS (code=1, address=0x0000000000)Why? The CounterVM in CounterView was released when the outer NavigationView was popped, but NextView still holds a reference to its own CounterVM that was never released, causing a dangling pointer when the view hierarchy is torn down.
Root Causes of the Crash
Multiple ownership – Two views each own a
@StateObjectthat should be shared.Nested navigation stacks – Each stack has its own lifecycle, so objects can be released unexpectedly.
Improper initialization – Initializing
@StateObjectin a computed property or inside a function that gets called multiple times.Weak references in closures – Capturing a view model weakly inside a closure that outlives the view.
When SwiftUI deallocates a view, it also deallocates its @StateObject. If another view still references that instance, the runtime throws EXC_BAD_ACCESS.
Common Fixes
1. Promote the state object to a higher level
class GlobalCounter: ObservableObject {
static let shared = GlobalCounter()
@Published var count = 0
}
struct CounterView: View {
@StateObject private var vm = GlobalCounter.shared
var body: some View { /* ... */ }
}Now all views share the same instance, so it won’t be deallocated unexpectedly.
2. Use @EnvironmentObject for shared state
struct CounterView: View {
@EnvironmentObject var vm: GlobalCounter
// ...
}Inject the object once at the root:
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
CounterView()
.environmentObject(GlobalCounter.shared)
}
}
}3. Remove nested NavigationViews
As shown earlier, keep a single navigation stack.
4. Avoid re‑initializing in NavigationLink
NavigationLink(destination: DetailView()) { Text("Go") }Don’t create a new @StateObject inside the destination if you need to preserve state across navigation.
Using @EnvironmentObject for Shared State
@EnvironmentObject is designed for data that needs to flow through many views. It solves the ownership problem by letting a single source of truth live at the top of the view hierarchy.
class AuthManager: ObservableObject {
@Published var isLoggedIn = false
}
struct ContentView: View {
@StateObject private var auth = AuthManager()
var body: some View {
NavigationView {
if auth.isLoggedIn {
DashboardView()
} else {
LoginView()
}
}
.environmentObject(auth) // inject
}
}Both DashboardView and LoginView can now access auth via @EnvironmentObject without worrying about lifecycle.
Proper Lifecycle Management
When you need a view model that lives only as long as a specific view, use @StateObject. When the object should outlive the view (e.g., a network manager), use @ObservedObject or @EnvironmentObject.
struct DetailView: View {
@ObservedObject var vm: DetailVM // injected from parent
var body: some View { /* ... */ }
}This pattern ensures the parent owns the object, preventing accidental deallocation.
Debugging Tools & Techniques
Xcode Memory Graph – Capture a snapshot when the crash occurs. Look for dangling references or unexpected deallocations.
Enable Zombies – In Xcode’s scheme diagnostics, turn on “Enable Zombie Objects”. This turns deallocated objects into “zombies” that throw a descriptive exception instead of an
EXC_BAD_ACCESS.Print Deinit – Add a
deinitto your view model and log when it’s called.
class CounterVM: ObservableObject {
@Published var count = 0
deinit { print("CounterVM deinit") }
}If you see the deinit log before the crash, you know the object is being released too early.
Use the
swiftdebugger – Set a breakpoint ondeinitand watch the stack when it’s hit.
Quick Wins to Avoid Future Crashes
One navigation stack per screen flow – Don’t nest
NavigationViews unless you truly need a separate stack.Inject shared objects at the top – Use
environmentObjectfor global state.Prefer
@StateObjectfor view‑local state – Keep it private and avoid re‑initialization.Turn on Zombies during development – It’s a quick way to catch dangling references before shipping.
Real‑world scenario: In a payment app, the checkout flow uses nested
NavigationViews for the cart and payment screens. When a user goes back from the payment screen, the cart’s@StateObjectis deallocated while the payment screen still holds a reference, leading to a crash. By moving the cart’s view model to a shared@EnvironmentObjectinjected at the app launch, the crash disappears.
Wrapping Up
I’ve spent countless nights debugging EXC_BAD_ACCESS crashes that looked like random bugs. The real culprit was usually a mismatch between view ownership and the SwiftUI view hierarchy. By keeping a single navigation stack, promoting shared state to @EnvironmentObject, and being mindful of when @StateObject is initialized, I’ve turned those night‑time panic sessions into smooth releases.
If you’re building a complex navigation flow, think about who truly owns each piece of state. It’s not just about “does it compile?” it’s about “does it survive the user’s journey?”
For more on building robust payment flows, check out How to Build a Fully Functional Payment Gateway. It dives into state management across multiple screens.
FAQs
Why does
EXC_BAD_ACCESShappen even though I’m using@StateObject?
It usually means the object was deallocated while a view still referenced it often due to nested navigation stacks or re‑initializing the object in a computed property.Can I use
@ObservedObjectinstead of@StateObjectto avoid crashes?@ObservedObjectassumes the owner lives elsewhere. If you create it inside the view, you risk the same deallocation issue. Use@StateObjectfor view‑owned objects.How do I debug a crash that says
EXC_BAD_ACCESS (code=1, address=0x0000000000)?
Enable Zombies, check the memory graph, and adddeinitprints to track when objects are released.Is it okay to nest
NavigationViews for modal presentations?
Only if you truly need a separate navigation stack. Otherwise, usesheetorfullScreenCoverto present modals.What’s the difference between
@EnvironmentObjectand passing objects via initializer?@EnvironmentObjectis great for global, shared state; initializer injection is better for tightly coupled view models that don’t need to be shared.
Happy coding, and may your SwiftUI apps stay crash‑free!




