The hidden cost of unstable SwiftUI environment defaults
The @Entry macro lets us define a custom SwiftUI environment value and its default in one declaration. Starting in Xcode 27, the compiler shows a warning when we create a class instance directly in the default expression. Every fallback access then returns a new reference, which can make SwiftUI treat the environment value as changed and re-evaluate views that read it.
In this post, we will look at how an unstable environment default causes extra view updates and how to provide a stable default instead.
# Extra view updates with unstable environment defaults
Imagine that we are building a reading app where views report user interactions through a shared logger. The logger is not view data and does not need to be observable, so we store it in a custom environment value that can be replaced for previews, tests, or different parts of the app.
final class AppLogger {
func logArticleOpened(id: Article.ID) {
// Send the event to the configured logging destination.
}
}
extension EnvironmentValues {
@Entry var logger = AppLogger()
}
In Xcode 27, this declaration shows a new warning.
Warning
Storing a class type in '@Entry var logger' may invalidate dependents on every update because the default value is reallocated on every access. Initialize the default value elsewhere and reference it from the '@Entry' declaration.
@Entry generates an environment key whose default value is returned from a computed getter. When an environment lookup does not find an injected logger, SwiftUI evaluates this getter and runs the AppLogger() initializer. Because class instances are compared by reference identity, every fallback access produces a different value from the previous one.
To see how this affects view updates in practice, we can add a second environment value to our example. The ArticleRow view only reads the logger, while ArticleList updates the new density value from the toolbar. We also add _printChanges() to the row body so its re-evaluations appear in the console.
enum ArticleListDensity {
case compact
case comfortable
}
extension EnvironmentValues {
@Entry var articleListDensity = ArticleListDensity.comfortable
}
struct ArticleRow: View {
let article: Article
@Environment(\.logger)
private var logger
var body: some View {
let _ = Self._printChanges()
Button {
logger.logArticleOpened(id: article.id)
} label: {
VStack(alignment: .leading) {
Text(article.title)
Text("\(article.readingTime) min read")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
struct ArticleList: View {
let articles: [Article]
@State private var density = ArticleListDensity.comfortable
var body: some View {
List(articles) { article in
ArticleRow(article: article)
}
.environment(\.articleListDensity, density)
.toolbar {
Button("Change Density") {
density = density == .compact
? .comfortable
: .compact
}
}
}
}
When we press the toolbar button, articleListDensity changes and ArticleList's body is re-evaluated as expected. But SwiftUI also checks the environment values read by ArticleRow to determine if they changed. The row falls back to the default logger again, which returns a new AppLogger reference. SwiftUI treats the logger as changed and re-evaluates the row body too, even though the row does not read articleListDensity and the logger itself was never updated.
This only happens when a view falls back to the default. If a logger is injected above ArticleRow, SwiftUI uses that instance instead of evaluating the default expression.
# Providing stable default environment values
To make the default environment values stable, we can create them outside the @Entry declaration and reference them from the default expression.
Here is how this can be done in our example with the logger.
private let defaultLogger = AppLogger()
extension EnvironmentValues {
@Entry var logger = defaultLogger
}
The getter generated by @Entry is still evaluated on every fallback access, but it now returns the same reference. SwiftUI can compare it with the previous value and avoid re-evaluating ArticleRow when unrelated environment values change.
If the environment value should always be supplied by the app, we can avoid providing a fallback instance and make it optional instead.
extension EnvironmentValues {
@Entry var logger: AppLogger?
}
The app can then create and configure the logger once and inject it into the hierarchy.
@main
struct ReaderApp: App {
private let appLogger = AppLogger()
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.logger, appLogger)
}
}
}
Views that read the value have to handle the missing dependency explicitly. This is a better fit when silently using an unconfigured default would hide an app configuration error.
Xcode 27 shows the warning when the @Entry type is a class, but reference types are not the only defaults that can be unstable. Expressions such as Date(), UUID(), and value types containing a newly created class instance can also produce a different value on every access.
A value type does not need to conform to Equatable to make a suitable environment default. SwiftUI can compare its stored fields, so fixed literals, enum cases without changing associated values, nil, and structs containing stable references can all be valid defaults. The important question is whether evaluating the expression repeatedly produces the same effective value. For a class instance, moving it to stable storage ensures that every fallback access returns the same reference.
If you are looking to build a strong foundation in SwiftUI, my book SwiftUI Fundamentals takes a deep dive into the framework's core principles and APIs to help you understand how it works under the hood and how to use it effectively in your projects. And my new book The SwiftUI Way helps you adopt recommended patterns, avoid common pitfalls, and use SwiftUI's native tools appropriately to work with the framework rather than against it.
For more resources on Swift and SwiftUI, check out my other books and book bundles.



