Custom environment values are useful when a whole part of the view tree needs access to the same app-level dependency. Navigation is a good example: a feature root can own a navigator, and child views can ask it to open another screen without passing that navigator through every initializer.

The annoying part has always been the setup code.

For a long time, this was the normal shape:

@MainActor
@Observable
final class Navigator {
    var path: [Route] = []

    func openProfile(id: User.ID) {
        path.append(.profile(id))
    }
}

private struct NavigatorEnvironmentKey: EnvironmentKey {
    static let defaultValue = Navigator()
}

extension EnvironmentValues {
    var navigator: Navigator {
        get { self[NavigatorEnvironmentKey.self] }
        set { self[NavigatorEnvironmentKey.self] = newValue }
    }
}

That still works, but most of the code is just scaffolding around one stored value.

SwiftUI’s @Entry macro lets the declaration say the same thing directly:

extension EnvironmentValues {
    @Entry var navigator = Navigator()
}

The default keeps previews and isolated views easy to render, but the real feature still injects the navigator owned by its NavigationStack.

You read it the same way as before:

struct ProductRow: View {
    let product: Product

    @Environment(\.navigator) private var navigator

    var body: some View {
        Button(product.title) {
            navigator.openProfile(id: product.ownerID)
        }
    }
}

And you set it from the feature root that owns the NavigationStack:

struct ProductsFeature: View {
    @State private var navigator = Navigator()

    var body: some View {
        NavigationStack(path: $navigator.path) {
            ProductListView()
                .environment(\.navigator, navigator)
                .navigationDestination(for: Route.self) { route in
                    switch route {
                    case .profile(let id):
                        ProfileView(id: id)
                    }
                }
        }
    }
}

The macro creates the storage key and the accessor that EnvironmentValues needs. The public API your views use is still the key path, \.navigator.

Adding a View Modifier

I usually still add a small view modifier for values that are part of my app’s own UI vocabulary:

extension View {
    func navigator(_ navigator: Navigator) -> some View {
        environment(\.navigator, navigator)
    }
}

That gives the call site a nicer shape:

ProductListView()
    .navigator(navigator)

This is not required, but it is often worth doing for values that appear in previews, feature roots, or tests. It also mirrors SwiftUI’s own style: you normally write .lineLimit(2), not .environment(\.lineLimit, 2).

Why I Prefer @Entry

The main benefit is not that it saves a few lines. The benefit is that the important part becomes visible.

With the old version, every custom value needs a key type, a default value, a getter, and a setter. In code review, I do not want to spend attention checking whether the getter and setter point at the same key type.

With @Entry, the declaration says exactly what matters:

extension EnvironmentValues {
    @Entry var isPaywallEnabled = false
    @Entry var analyticsClient: AnalyticsClient = .telemetry
    @Entry var navigator = Navigator()
}

That is easier to scan. It is also harder to accidentally copy the wrong key name from another environment value.

It is especially useful for app-level values that are simple by design:

  • feature flags used by a group of views
  • lightweight clients or closures for actions
  • display configuration for a component family
  • app or feature dependencies with a sensible preview or test default

The old pattern made these values feel heavier than they really were. @Entry puts them closer to the amount of code they deserve.

The Trade-Offs

The trade-off is that a macro hides generated code. In this case I think that is a good default, but it is still a trade-off.

When I see this:

@Entry var navigator = Navigator()

I know SwiftUI is generating the key type for me, but I am no longer naming that key myself. For most app code, that is fine. The key type was implementation detail anyway.

It also means the default value is part of the declaration. That is usually pleasant, but it can make the declaration too dense if the default is expensive, needs setup, or deserves a name. In those cases I would move the default somewhere explicit:

extension AnalyticsClient {
    static let preview = AnalyticsClient { _ in }
}

extension EnvironmentValues {
    @Entry var analyticsClient: AnalyticsClient = .preview
}

The other practical trade-off is tooling. Apple introduced @Entry at WWDC24 as a SwiftUI API, and it depends on the compiler understanding macros. Swift macros arrived with Swift 5.9, but the generated code can still run on older deployment targets, including iOS 13, because the macro expands at compile time into ordinary environment-key code. If a package or app still needs to build with older Xcode versions, the explicit EnvironmentKey version is the safer choice.

Limitations

The main limitation is simple: @Entry is for straightforward stored values. If the environment accessor needs custom behavior, use the explicit EnvironmentKey version.

For example, if I wanted to clamp a value before it enters the environment, I would keep the old pattern:

private struct RefreshIntervalKey: EnvironmentKey {
    static let defaultValue: TimeInterval = 30
}

extension EnvironmentValues {
    var refreshInterval: TimeInterval {
        get { self[RefreshIntervalKey.self] }
        set { self[RefreshIntervalKey.self] = max(5, newValue) }
    }
}

That extra code is doing real work. The setter documents a rule, and the rule is enforced at the boundary where the value enters the environment.

The other limitation is compatibility. @Entry needs a SwiftUI SDK and compiler that understand the macro. If a module still has to build with older Xcode versions, the explicit key is safer.

When I Would Still Use the Old Pattern

I would keep the old EnvironmentKey style when:

  • the getter or setter needs custom behavior
  • the code must build with older Xcode versions

That is about it. @Entry should be the default for ordinary custom environment values. Reach for the longer EnvironmentKey version only when the accessor needs to do something special, or when the project still has to build with an older toolchain.