SwiftUI @Entry Macro: Custom EnvironmentValues, FocusedValues, and Transaction Keys Without Boilerplate
The SwiftUI @Entry macro replaces the classic EnvironmentKey boilerplate with a single line. Here's how to use it for EnvironmentValues, FocusedValues, Transaction, and ContainerValues in iOS 26 apps.
The SwiftUI @Entry macro is a Swift 5.9 attached macro that replaces the multi-line EnvironmentKey boilerplate with a single property declaration inside an extension on EnvironmentValues, FocusedValues, Transaction, or (as of iOS 18) ContainerValues. Instead of declaring a key type, conforming it to a protocol, and adding a computed property, you write one line: @Entry var accent: Color = .blue. The macro expands into everything the compiler needs, keeps the default value inline with the declaration, and works identically on iOS 26, macOS 15, visionOS 2, watchOS 11, and tvOS 18.
@Entry ships with SwiftUI for iOS 18+, macOS 15+, visionOS 2+, watchOS 11+, and tvOS 18+; earlier targets must use the classic EnvironmentKey pattern.
It works in extensions on four types: EnvironmentValues, FocusedValues, Transaction, and ContainerValues.
The default value is written inline (@Entry var flag: Bool = false), so no separate defaultValue declaration is required.
Values stay scoped to the view subtree they are set on, and previews inherit the default declared with @Entry.
@Entry is a peer macro; it does not change ABI, and existing EnvironmentKey definitions continue to work alongside it.
On Mac Catalyst and macOS, FocusedValues populated with @Entry are how you make menu commands react to the currently focused window.
What is the @Entry macro in SwiftUI?
The @Entry macro is a SwiftUI-provided attached macro that generates the plumbing required to add a new keyed value to one of SwiftUI's four dynamic value containers. When you write @Entry var toolbarTint: Color = .accentColor in an extension on EnvironmentValues, the macro expands at compile time into a private key type conforming to EnvironmentKey, a defaultValue that returns your inline default, and a computed property backed by that key. You never see the expanded code unless you right-click the attribute and choose Expand Macro, but it is real Swift and shows up in the symbol graph exactly as if you had written it by hand.
Apple introduced @Entry at WWDC24 to shave the ceremony off a pattern almost every SwiftUI codebase used. It is part of the same wave that produced @Observable and @Bindable (see our complete guide to the @Observable macro for the same pattern applied to state observation). Because it is a peer macro backed by SwiftUI's own package, no additional import is required; the macro is available as soon as you import SwiftUI on a supported OS. The official Entry macro documentation confirms availability starts at iOS 18, macOS 15, visionOS 2, watchOS 11, and tvOS 18.
Before @Entry: the EnvironmentKey boilerplate
To appreciate why @Entry exists, it helps to see the pattern it replaces. Adding a single custom environment value used to require three parts: a key type, a computed property, and (for anything non-trivial) a documentation comment on both. In an app I shipped last spring, the shared toolbar tint alone accounted for seventeen lines split across two files. Here's that pattern in full, with an inline default color:
// The pre-@Entry pattern (still valid on iOS 17 and earlier)
import SwiftUI
private struct ToolbarTintKey: EnvironmentKey {
static let defaultValue: Color = .accentColor
}
extension EnvironmentValues {
var toolbarTint: Color {
get { self[ToolbarTintKey.self] }
set { self[ToolbarTintKey.self] = newValue }
}
}
Everything works, but you're repeating the property name three times and the type twice. Rename the key and you touch three sites. Add a doc comment and you either duplicate it or leave one site undocumented. The pattern also encourages defensive habits that don't help. I've seen teams gate their custom keys behind file-private structs simply to avoid the noise of a nested namespace, then lose the ability to reference the key type in tests. The macro removes the reason to make those trade-offs at all, without breaking any code that already uses the classic form. Mixing @Entry declarations and hand-written EnvironmentKey declarations in the same extension is fully supported, and that's how most codebases will migrate.
How do I create a custom EnvironmentValues in SwiftUI?
With @Entry, the seventeen-line example above collapses to a single line:
import SwiftUI
extension EnvironmentValues {
@Entry var toolbarTint: Color = .accentColor
@Entry var isCompactSidebar: Bool = false
@Entry var undoLimit: Int = 32
}
The macro generates a private EnvironmentKey for each property, wires the getter and setter, and uses the initializer expression as the default value. You consume it exactly as before, with the @Environment property wrapper on any view in the subtree where the value has been set:
struct ArticleToolbar: View {
@Environment(\.toolbarTint) private var tint
var body: some View {
Button("Publish") { }
.tint(tint)
}
}
struct RootView: View {
var body: some View {
ArticleToolbar()
.environment(\.toolbarTint, .purple) // set for this subtree
}
}
A few practical rules to internalise. The inline default expression is evaluated lazily, so referring to a value that's expensive to compute (a decoded image, a database handle) is fine. It only runs when a view actually asks for it and no upstream .environment(_:_:) has overridden it. The property must be declared in an extension on EnvironmentValues; putting @Entry on a stored property inside a view or model raises a diagnostic. Type inference works from the default expression, but you can also write the type explicitly: @Entry var undoLimit: Int = 32 and @Entry var undoLimit = 32 both compile, and honestly, I prefer the explicit form when reviewing PRs because it makes the API surface obvious at a glance. Finally, because the generated key type is private, you can't inspect it from a test target. The testing section below explains the pattern I use instead.
Using @Entry with FocusedValues for menu commands
The most under-appreciated use of @Entry is on FocusedValues. FocusedValues is how SwiftUI passes information from the currently focused scene up to the app's Commands: the menu bar on macOS, the hardware-keyboard menus on iPadOS, and the discoverable command list on visionOS. Before @Entry, plumbing a piece of state into a menu item required the same boilerplate as environment values but with a different key protocol, FocusedValueKey. With @Entry, the pattern is symmetrical:
extension FocusedValues {
@Entry var currentDocument: Document?
@Entry var canUndo: Bool = false
}
struct EditorView: View {
@State private var document: Document
var body: some View {
TextEditor(text: $document.body)
.focusedSceneValue(\.currentDocument, document)
.focusedSceneValue(\.canUndo, document.undoManager?.canUndo == true)
}
}
struct DocumentCommands: Commands {
@FocusedValue(\.currentDocument) private var document
@FocusedValue(\.canUndo) private var canUndo
var body: some Commands {
CommandGroup(replacing: .undoRedo) {
Button("Undo") { document?.undo() }
.disabled(canUndo != true)
.keyboardShortcut("z")
}
}
}
The value is nil when nothing in the current focus tree has published it, which is why the currentDocument declaration uses an optional type without a default expression (@Entry var currentDocument: Document?). That's a small but important distinction from environment values, where you almost always want a non-nil default. On Mac Catalyst apps built for iPad, FocusedValues populated this way drive both the menu bar and the hardware-keyboard menu that appears when the user long-presses the Cmd key. On visionOS, they populate the ornament-attached command list. Same declaration, three different rendering paths. This is one of the reasons I prefer @Entry-backed FocusedValues over ad hoc singletons for cross-platform document apps.
Extending Transaction with @Entry for animation context
The third container @Entry supports is Transaction, and this one is easy to miss because SwiftUI's built-in transaction keys (animation, disablesAnimations, isContinuous) already cover the common cases. Custom transaction values are the right tool when you need to pass animation metadata that only some animatable modifiers care about. For instance, a "source of change" marker so a chart can distinguish a user-initiated zoom from a data refresh and choose different spring parameters. Before @Entry, this required a TransactionKey conformance; now it's one line:
Any view participating in the animation can read the value via Transaction.current.animationSource or a custom AnimatableModifier that inspects the transaction in body(content:). The value propagates across view boundaries as long as the state change stays inside the withTransaction block, and it survives interpolation because a Transaction is copied per frame. On Mac Catalyst, transactions triggered by a menu command inherit the Transaction.current of the invoking dispatch, which means an @Entry-backed source marker works uniformly whether the change originated in a touch gesture on iPad or a Cmd-Z on macOS. If you want a wider tour of animation infrastructure, the SwiftUI animations guide covers how transactions compose with springs and keyframes.
@Entry and ContainerValues in iOS 26
iOS 18 added a fourth container to the @Entry supported list: ContainerValues. In iOS 26, Apple rounded out the API with helpers for List, ForEach, and the new Tab DSL, which makes ContainerValues genuinely useful in production for the first time. Unlike EnvironmentValues, which propagate down a view's subtree, ContainerValues propagate sideways. A parent container reads them from its direct child views. This is how you build a custom container that lets its children opt into or out of features without threading configuration through every call site.
extension ContainerValues {
@Entry var badge: Int? = nil
@Entry var isPinned: Bool = false
}
extension View {
func badge(_ count: Int?) -> some View {
containerValue(\.badge, count)
}
func pinned(_ pinned: Bool = true) -> some View {
containerValue(\.isPinned, pinned)
}
}
struct PinnableList<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
ForEach(subviews: content) { subview in
HStack {
if subview.containerValues.isPinned { Image(systemName: "pin.fill") }
subview
if let n = subview.containerValues.badge { Text("\(n)").monospacedDigit() }
}
}
}
}
The consumer writes PinnableList { RowA().pinned(); RowB().badge(3) } and the container reads each child's values without the child having to know it's inside a PinnableList. This is a genuine architectural shift: it lets you build List-like containers that are as ergonomic as SwiftUI's own. On iPad in Stage Manager and on visionOS, the same declarations power context-menu sections that adapt to the active window's contents. If you build custom containers, the pattern in the SwiftUI Custom Layout Protocol guide pairs naturally with ContainerValues.
How @Entry behaves on iPad, Mac Catalyst, and visionOS
The macro expansion is identical on every Apple platform, but the values you inject through it are read by platform-specific machinery, and that machinery has different rules. Three practical differences worth knowing.
iPadOS multiple windows. Each UIWindowScene gets its own environment tree. If you set an @Entry-backed value at the app root, it applies to every scene; if you set it inside a WindowGroup's content, it only applies to that window. The most common bug I see is teams caching an environment value in a class-based store, then wondering why the second window sees stale data. Read the value from the environment at the point of use, not once in init.
Mac Catalyst.FocusedValues are the primary bridge between your app content and the AppKit-backed menu bar. If your Catalyst app has an @Entry-declared FocusedValue that never appears in menus, the usual cause is that no view in the currently key window has called .focusedSceneValue. Unlike environment values, focused values are only meaningful when something in the focus tree has claimed focus.
visionOS. An immersive space uses a separate scene, so environment values set in your main WindowGroup don't automatically flow into an ImmersiveSpace. If you want the same tint or user preference in both, set it in the App body via .environment or read it from a shared @Observable model. The Apple EnvironmentValues reference spells out the scene semantics if you want the formal version.
Common @Entry macro errors and gotchas
The compiler diagnostics for @Entry are unusually precise, but a few recurring mistakes still eat afternoons. The most common: applying @Entry to a property that isn't inside an extension on one of the four supported containers. The macro simply refuses to expand and you get "@Entry can only be applied to properties within extensions on EnvironmentValues, FocusedValues, Transaction, or ContainerValues." Move the property into the right extension and the error clears.
Second, @Entry requires either an inline default expression or an optional type. @Entry var mode: Mode without a default and without Optional is an error. This is intentional: SwiftUI's key protocols require a default, and the macro won't synthesise an arbitrary one for a custom enum.
Third, @Entry doesn't support @available attribute inheritance the way you might expect. If you want to gate a specific value behind iOS 26, apply @available(iOS 26.0, *) to the property itself; the macro-generated key type inherits the same availability. Applying @available only to the extension isn't sufficient because the generated key is nested inside the extension scope.
Fourth (and this is the one that bit me hardest last quarter), @Entry in an @_spi-marked extension does not currently propagate the SPI attribute to the generated key type. If you rely on SPI to hide a value from downstream targets, verify with Expand Macro before shipping. The SE-0389 attached macros proposal explains why attribute propagation works this way.
Testing @Entry values in previews and unit tests
Because @Entry generates a private key type, you can't import the key into a test target and inject a value directly. In practice, that's fine, because you rarely want to unit-test the storage. You want to test that a view responds correctly when a value changes. Both patterns work well:
#Preview("Purple tint") {
ArticleToolbar()
.environment(\.toolbarTint, .purple)
}
// Swift Testing
import Testing
import SwiftUI
@Test func toolbarUsesInjectedTint() throws {
let view = ArticleToolbar()
.environment(\.toolbarTint, .red)
// Render into a snapshot host or use ViewInspector to assert the applied tint.
}
For previews, the important detail is that .environment(_:_:) is the injection point. The key path uses the property name you declared with @Entry, not the generated key. Previews inherit the default value declared inline, so a preview with no explicit override always shows what a first-time user in the shipping app would see. For unit tests, I lean on the same technique the Swift Testing guide demonstrates: assert against observable side effects (a modifier that applies the color, a computed property in a model) rather than trying to read the environment directly. If you truly need to read an @Entry value from outside SwiftUI's render loop, capture it inside a hosting view's body and forward it through a closure. Don't reach into the generated key.
Frequently Asked Questions
Does the @Entry macro work on iOS 17?
No. @Entry requires iOS 18, macOS 15, visionOS 2, watchOS 11, or tvOS 18 as the deployment target because it ships with the SwiftUI framework on those OS versions. For iOS 17 and earlier you must use the classic EnvironmentKey pattern. Both patterns can coexist in the same extension while you migrate.
What is the difference between @Environment and @EnvironmentObject?
@Environment reads a keyed value from EnvironmentValues, whether a scalar, a struct, or a closure. @EnvironmentObject reads a shared reference-type observable model from the environment by type. In modern SwiftUI you inject observable models with .environment(model) and read them with @Environment(MyModel.self), which unifies both concepts. Use @Entry when the value is small and keyed; use environment-injected observables for shared mutable state.
Can I put @Entry on a stored property inside a view?
No. @Entry only expands inside extensions on EnvironmentValues, FocusedValues, Transaction, or ContainerValues. If you apply it to a view's stored property, the compiler emits a diagnostic and skips expansion. For state inside a view use @State; for observable models use @Environment combined with the @Observable macro.
Does @Entry work with generic types?
Yes, provided the generic parameter is fully resolved at the extension site. @Entry var picker: PickerConfiguration<String> = .default compiles; a generic @Entry var picker: PickerConfiguration<T> in a generic extension does not, because the generated key type would itself need to be generic and SwiftUI's key protocols require concrete conformances.
Is @Entry the same as the @Entry attribute in swift-argument-parser?
No, the names collide but the tools are unrelated. SwiftUI's @Entry is a peer macro for injecting values into SwiftUI containers. There is no @Entry in swift-argument-parser; its equivalents are @Argument, @Option, and @Flag. If a search result conflates them, it's out of date or wrong.
A production-ready guide to bridging SwiftUI and UIKit in iOS 26: UIViewRepresentable, UIViewControllerRepresentable, coordinators, UIHostingController, sizing, and the pitfalls that trip up mixed codebases.
SwiftUI @FocusState binds view focus to a value. Learn iOS 26 patterns for keyboard toolbars, focus chains, sheet dismissals, OTP inputs, and hardware keyboard support with working code.
How to use SwiftUI PhotosPicker in iOS 26 for multi-select, video filtering, and file-based loading. Covers race conditions, iCloud progress, and VoiceOver.