DEV Community

Cover image for When NOT to Use `@EnvironmentObject` in SwiftUI
ArshTechPro
ArshTechPro

Posted on

When NOT to Use `@EnvironmentObject` in SwiftUI

If you've been working with SwiftUI for any length of time, you've probably reached for @EnvironmentObject at some point. It's convenient, it's clean, and it makes passing data down a deep view hierarchy feel almost effortless. But like most powerful tools, it comes with trade-offs that aren't always obvious until you've shipped a bug to production.

This article isn't about bashing @EnvironmentObject. It's about understanding it well enough to know when it's the wrong choice.


What is @EnvironmentObject, really?

Before we talk about when not to use something, we need to be clear about what it actually does.

@EnvironmentObject is a property wrapper that lets a view access a shared object that's been injected into the SwiftUI environment. The object must conform to ObservableObject, and when any of its @Published properties change, all views observing that object will re-render.

Here's a minimal example:

class UserSettings: ObservableObject {
    @Published var username: String = "Guest"
}

struct ParentView: View {
    @StateObject private var settings = UserSettings()

    var body: some View {
        ChildView()
            .environmentObject(settings)
    }
}

struct ChildView: View {
    var body: some View {
        GrandchildView()
    }
}

struct GrandchildView: View {
    @EnvironmentObject var settings: UserSettings

    var body: some View {
        Text("Hello, \(settings.username)")
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice that ChildView doesn't know anything about UserSettings. The object skips right over it and lands in GrandchildView. That's the appeal: you don't have to thread dependencies through every intermediate view.

How does it work under the hood?

When you call .environmentObject(settings), SwiftUI stores a reference to that object in the view's environment dictionary, keyed by the object's type. When a child view declares @EnvironmentObject var settings: UserSettings, SwiftUI looks up UserSettings.self in the environment and hands back the reference.

If it can't find a matching object, your app crashes. No compile-time warning. No graceful fallback. Just a runtime crash with a message like:

Fatal error: No ObservableObject of type UserSettings found.
Enter fullscreen mode Exit fullscreen mode

This behavior is intentional. SwiftUI treats a missing environment object as a programmer error, not a recoverable condition.


When NOT to use @EnvironmentObject

Now that we're on the same page about what @EnvironmentObject does, let's get into the situations where you should think twice before using it.

1. When the dependency is only needed by one or two views

If your shared object is only consumed by a single view, or by a parent and its immediate child, @EnvironmentObject adds indirection without providing benefit.

Consider this case:

struct ProfileView: View {
    @EnvironmentObject var userProfile: UserProfile

    var body: some View {
        VStack {
            Text(userProfile.name)
            Text(userProfile.email)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

If ProfileView is the only consumer of UserProfile, you've introduced implicit coupling. Someone reading the code has to trace back through the view hierarchy to find where the object was injected. Compare that to:

struct ProfileView: View {
    let userProfile: UserProfile

    var body: some View {
        VStack {
            Text(userProfile.name)
            Text(userProfile.email)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the dependency is explicit. You can see what ProfileView needs just by looking at its declaration. This matters when your codebase grows and you're trying to understand what a view depends on without running the app.

Use explicit parameters when the object doesn't need to travel through multiple layers of the view hierarchy.

2. When working with reusable components

This one bites people regularly. You build a reusable view component, say a custom button or a card layout, and you wire it up with @EnvironmentObject because it needs some shared state.

struct FavoriteButton: View {
    @EnvironmentObject var favorites: FavoritesManager
    let itemID: String

    var body: some View {
        Button(action: { favorites.toggle(itemID) }) {
            Image(systemName: favorites.contains(itemID) ? "heart.fill" : "heart")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This works fine in your app. Then you try to use FavoriteButton in a SwiftUI preview:

struct FavoriteButton_Previews: PreviewProvider {
    static var previews: some View {
        FavoriteButton(itemID: "123")
        // Crash: No ObservableObject of type FavoritesManager found.
    }
}
Enter fullscreen mode Exit fullscreen mode

You fix it by adding .environmentObject(FavoritesManager()). But now imagine you want to share this component with another project, or publish it as a package. Every consumer has to know about FavoritesManager and provide it, even if their favoriting logic is completely different.

Reusable components should take explicit dependencies through their initializer or use closures for actions. This keeps them portable and testable without requiring specific environment objects.

struct FavoriteButton: View {
    let isFavorited: Bool
    let action: () -> Void

    var body: some View {
        Button(action: action) {
            Image(systemName: isFavorited ? "heart.fill" : "heart")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the component is dumb about where its data comes from. The parent can wire it up however it wants.

3. When you need to test views in isolation

Unit testing SwiftUI views is already tricky. Adding @EnvironmentObject makes it trickier because you have to set up the environment for every test.

func testProfileDisplaysCorrectName() {
    let mockProfile = UserProfile(name: "Test User", email: "test@example.com")
    let view = ProfileView()
        .environmentObject(mockProfile)
    // Now you can test...
}
Enter fullscreen mode Exit fullscreen mode

This isn't terrible for one test, but it compounds. If you have ten views that all use the same environment object, every test file needs to know how to construct and inject that object. If the environment object has its own dependencies, you're now setting up a whole object graph for what should be a simple view test.

Views with explicit dependencies are easier to instantiate in tests:

func testProfileDisplaysCorrectName() {
    let mockProfile = UserProfile(name: "Test User", email: "test@example.com")
    let view = ProfileView(userProfile: mockProfile)
    // Simpler.
}
Enter fullscreen mode Exit fullscreen mode

4. When the object needs to exist conditionally

@EnvironmentObject assumes the object always exists. There's no optional variant built into the property wrapper. If you have a scenario where the object might not exist, for example user authentication state where the user object only exists after login, @EnvironmentObject becomes awkward.

Some developers work around this by creating a wrapper object that's always present but contains an optional:

class AuthState: ObservableObject {
    @Published var currentUser: User?
}
Enter fullscreen mode Exit fullscreen mode

This works, but now every view that accesses AuthState has to handle the optional. You're essentially using @EnvironmentObject to pass around an optional, which feels like a mismatch between the tool and the problem.

If the dependency is conditional, consider passing it explicitly as an optional parameter, or restructuring your view hierarchy so that certain views only appear when the dependency exists.

5. When you're passing data into a NavigationLink destination

This is a subtle one. When you use NavigationLink, the destination view is created at the time you declare the NavigationLink, not when the user taps it. That means if your destination relies on @EnvironmentObject, the environment object must be available when the parent view's body is evaluated.

struct ItemListView: View {
    @EnvironmentObject var store: ItemStore

    var body: some View {
        NavigationStack {
            List(store.items) { item in
                NavigationLink(destination: ItemDetailView(item: item)) {
                    Text(item.name)
                }
            }
        }
    }
}

struct ItemDetailView: View {
    let item: Item
    @EnvironmentObject var store: ItemStore

    var body: some View {
        // Uses both item and store
    }
}
Enter fullscreen mode Exit fullscreen mode

This usually works because the environment propagates down through NavigationStack. But if you're doing something unusual with your navigation structure, or if you're using NavigationLink outside of a NavigationStack, you can hit runtime crashes that are hard to diagnose.

More importantly, this creates tight coupling between your navigation structure and your dependency injection strategy. If you later decide to present ItemDetailView in a different context, say a sheet or a different navigation stack, you have to remember to inject the environment object there too.

6. When you need fine-grained observation

Here's a performance concern. When any @Published property on your environment object changes, every view observing that object gets marked for re-render. SwiftUI is smart about skipping unnecessary work, but it still has to evaluate each view's body to determine if the output changed.

class AppState: ObservableObject {
    @Published var username: String = ""
    @Published var notificationCount: Int = 0
    @Published var theme: Theme = .light
    @Published var recentSearches: [String] = []
    // ... and so on
}
Enter fullscreen mode Exit fullscreen mode

If you have a large environment object with many published properties, and views throughout your app observe it, you can end up with more view re-evaluation than necessary. A change to notificationCount triggers body evaluation in a view that only cares about username.

For large apps, consider splitting your state into multiple focused objects, or using the newer @Observable macro (iOS 17+) which provides more granular observation at the property level.

7. When SwiftUI previews become painful

I mentioned previews earlier, but it's worth emphasizing. @EnvironmentObject requires you to set up the environment for every preview. If your environment object has dependencies, those need to be set up too. If your app has multiple environment objects, every preview needs all of them.

struct SomeView_Previews: PreviewProvider {
    static var previews: some View {
        SomeView()
            .environmentObject(UserSettings())
            .environmentObject(NetworkMonitor())
            .environmentObject(ThemeManager())
            .environmentObject(FeatureFlags())
    }
}
Enter fullscreen mode Exit fullscreen mode

This gets old fast. Some teams end up creating preview helper functions or wrappers to avoid repeating this setup. That works, but it's ceremony that wouldn't exist if the views had explicit dependencies.


What to use instead

Depending on your situation, consider these alternatives:

Explicit initializer parameters are the simplest option. The dependency is visible, the view is easy to test, and there's no risk of runtime crashes from missing objects.

Closures for actions work well when a view needs to trigger something but doesn't need to know about the object that handles it. Pass a closure instead of the whole manager.

@StateObject at the point of use is appropriate when a view owns its state and doesn't need to share it with siblings.

@observable (iOS 17+) provides more efficient observation with less boilerplate than ObservableObject. Worth considering if you can target iOS 17 or later.

Custom EnvironmentKey with default values lets you create environment values that have sensible defaults, avoiding crashes when the value isn't explicitly provided.


Closing thoughts

@EnvironmentObject is a legitimate tool for passing shared state through a deep view hierarchy without prop drilling. It works well for truly app-wide concerns like authentication state, user preferences, or theme settings, things that many views need and that don't change frequently.


If you found this useful, I write about Swift, SwiftUI, and iOS development fairly regularly. Feel free to follow along.

Top comments (0)