---
title: 'Environment Values'
source: 'https://academia.sh/en/courses/swiftui/environment-values'
course: 'UI Development with SwiftUI'
language: en
updated: '2026-10-21T05:03:14+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Measures that an environment value recomputes a reader deep in the tree while skipping the views in between'
    - 'Shows that a system environment value and a custom environment key produce the same trigger'
    - 'Separates the fact that a change to a property of an object placed in the environment counts as observation, not environment'
    - 'Sees that overriding in a subtree requires a separate key in a single-key model'
---

# Environment Values

Measuring that an environment value recomputes only its reader along the tree without touching the views in between, that a property of an object placed in the environment triggers observation rather than environment, and how overriding in a subtree is built in the model.

In the previous lesson an object lived on its own, and to reach it we passed the object to every child as a parameter. The views in between had to carry it even when they had no interest in it. The **environment** removes this carrying: a value is placed at one point in the tree, and every view beneath it can read it directly. The trigger examined in this lesson is **environment**.

The measurement again uses the core file.

```swift
// Invalidation.swift — classifies which view a change recomputes and through which source
enum Storage {
    case state(owner: String)
    case model(object: String)
    case environment
}

struct ViewNode {
    let name: String
    let parent: String?
    let reads: Set<String>

    init(_ name: String, parent: String?, reads: Set<String>) {
        self.name = name
        self.parent = parent
        self.reads = reads
    }
}

struct Recompute {
    let view: String
    let cause: String
}

struct ViewTree {
    let nodes: [ViewNode]
    let storage: [String: Storage]

    func ancestors(of name: String) -> [String] {
        var found: [String] = []
        var current = nodes.first { $0.name == name }?.parent
        while let next = current {
            found.append(next)
            current = nodes.first { $0.name == next }?.parent
        }
        return found
    }

    func cause(of node: ViewNode, key: String) -> String {
        guard let kind = storage[key] else { return "out-of-scope" }
        switch kind {
        case .environment:
            return "environment"
        case .model:
            return "observed"
        case .state(let owner):
            if owner == node.name { return "local" }
            if ancestors(of: node.name).contains(owner) { return "parent" }
            return "off-rule"
        }
    }

    func change(_ label: String, key: String) -> [Recompute] {
        let hits = nodes.filter { $0.reads.contains(key) }
            .map { Recompute(view: $0.name, cause: cause(of: $0, key: key)) }
        let skipped = nodes.filter { !$0.reads.contains(key) }.map(\.name)
        let recomputed = hits.isEmpty ? "none" : hits.map { "\($0.view) \($0.cause)" }.joined(separator: ", ")
        let rest = skipped.isEmpty ? "none" : skipped.joined(separator: ",")
        print("\(label): \(key) changed | recomputed \(recomputed) | skipped \(rest)")
        return hits
    }
}

func countCauses(_ recomputes: [Recompute]) {
    for cause in ["local", "parent", "observed", "environment"] {
        print("\(cause): \(recomputes.filter { $0.cause == cause }.count)")
    }
}
```

`Storage.environment` says that a value belongs to no view and is a broadcast flowing down from that point in the tree. The `cause` function gives the `environment` trigger to every view that reads this key, without asking the ownership question. The model builds the framework's dependency-tracking rule; it is not the framework itself.

## A Reader Deep in the Tree

The framework supplies some environment values itself: the color scheme, the text size setting, the locale. The application can add its own keys too. A custom key is defined with an extension on `EnvironmentValues`, and the `@Entry` macro builds the key together with its default value. A view reads the value with `@Environment`; the value is placed in an ancestor view with the `.environment` modifier.

```swift
import SwiftUI

extension EnvironmentValues {
    @Entry var textScale: Int = 1
}

struct ReaderScreen: View {
    var body: some View {
        HStack {
            ArticleView()
            SidebarView()
        }
        .environment(\.textScale, 2)
    }
}

struct ArticleView: View {
    var body: some View {
        VStack {
            ParagraphView()
            CaptionView()
        }
    }
}

struct ParagraphView: View {
    @Environment(\.textScale) private var textScale

    var body: some View {
        Text("Scale \(textScale)")
    }
}

struct CaptionView: View {
    @Environment(\.textScale) private var textScale

    var body: some View {
        Text("Caption \(textScale)")
    }
}

struct SidebarView: View {
    @Environment(\.colorScheme) private var colorScheme

    var body: some View {
        Text(colorScheme == .dark ? "Dark" : "Light")
    }
}
```

`ArticleView` reads no environment value; it only hosts its two children. `ParagraphView` and `CaptionView` read the custom `textScale` key, and `SidebarView` reads the system's `colorScheme` value. In the tree these three reads are in `reads`. Two changes are applied: one to the value of the custom key, one to the system's color scheme. The first happens when the application changes the value in its `.environment` modifier, the second when the user changes the system setting; both are environment values.

```swift
// main.swift — an environment value recomputes only its reader, without touching what is in between
let storage: [String: Storage] = [
    "textScale": .environment,
    "colorScheme": .environment,
]

let screen = ViewTree(nodes: [
    ViewNode("ReaderScreen", parent: nil, reads: []),
    ViewNode("ArticleView", parent: "ReaderScreen", reads: []),
    ViewNode("ParagraphView", parent: "ArticleView", reads: ["textScale"]),
    ViewNode("CaptionView", parent: "ArticleView", reads: ["textScale"]),
    ViewNode("SidebarView", parent: "ReaderScreen", reads: ["colorScheme"]),
], storage: storage)

let recomputes = [
    screen.change("scale-changed", key: "textScale"),
    screen.change("mode-changed", key: "colorScheme"),
].flatMap { $0 }

countCauses(recomputes)
```

```
scale-changed: textScale changed | recomputed ParagraphView environment, CaptionView environment | skipped ReaderScreen,ArticleView,SidebarView
mode-changed: colorScheme changed | recomputed SidebarView environment | skipped ReaderScreen,ArticleView,ParagraphView,CaptionView
local: 0
parent: 0
observed: 0
environment: 3
```

In the `scale-changed` row, two views, `ParagraphView` and `CaptionView`, were recomputed; both are descendants, and the ancestor `ReaderScreen` is the view that places the value. `ArticleView` was in between and was skipped: because it does not read the environment value, the change did not touch it. In the `mode-changed` row only `SidebarView` was recomputed. All three recomputations are environment; in the count, environment is three and the rest are zero.

The gain of the environment is in that skipped row. If the value were carried two levels down the tree by parameter, `ArticleView` would have to take it as a parameter and pass it on, and it would itself count as a reader when the value changed. In the environment, the view in between does not know the value and is therefore not affected by the change.

**SN10.** This measurement covers two environment keys and two changes. The system's color scheme changes in reality with the user's setting; in the measurement the setting was represented as a key change, and how the framework delivers the change of the setting was not measured.

## An Object Placed in the Environment

Not only simple values are placed in the environment; an observable object can be placed too. The object is provided with `.environment(session)` and read with `@Environment(SessionStore.self)`. In this case two separate things can change: the object itself, or a property of the object.

```swift
import SwiftUI
import Observation

@Observable
final class SessionStore {
    var unreadCount = 0
}

struct InboxBadge: View {
    @Environment(SessionStore.self) private var session

    var body: some View {
        Text("Unread: \(session.unreadCount)")
    }
}
```

`InboxBadge` takes the object from the environment, but what it reads in its body is the object's `unreadCount` property. What is tracked is therefore not the environment itself but an object property. In the tree these two distinctions are written as two different storages: `textScale` is environment, `unreadCount` is an object property.

```swift
// main.swift — a property of an object placed in the environment triggers observation, not environment
let storage: [String: Storage] = [
    "textScale": .environment,
    "unreadCount": .model(object: "SessionStore"),
]

let screen = ViewTree(nodes: [
    ViewNode("ReaderScreen", parent: nil, reads: []),
    ViewNode("ParagraphView", parent: "ReaderScreen", reads: ["textScale"]),
    ViewNode("InboxBadge", parent: "ReaderScreen", reads: ["unreadCount"]),
], storage: storage)

let recomputes = [
    screen.change("scale-changed", key: "textScale"),
    screen.change("unread-changed", key: "unreadCount"),
].flatMap { $0 }

countCauses(recomputes)
```

```
scale-changed: textScale changed | recomputed ParagraphView environment | skipped ReaderScreen,InboxBadge
unread-changed: unreadCount changed | recomputed InboxBadge observed | skipped ReaderScreen,ParagraphView
local: 0
parent: 0
observed: 1
environment: 1
```

Two changes produced two recomputations, and the triggers differ: `scale-changed` is environment, `unread-changed` is observed. `InboxBadge` reads an object it reaches through the environment, but what recomputes it is not the environment but the object property. An object taken from the environment is only an access path; the place where the value is stored determines the trigger. This matches the rule of the third lesson: however and by whomever an object property is read, the trigger is observation.

The counts: observed one, environment one.

**SN11.** This measurement is only for a change to a property of the object. The case where the object in the environment is replaced entirely by another object, that is, a change of the environment value itself, was not measured; the environment trigger would be expected in that case, but the model did not represent it separately as a key change.

## The Bounding Measurement

An environment value can be **overridden** at a point in the tree: another value is placed for the same key in a subtree, and the views in that subtree read the overriding value rather than the upper one. A change to the upper value does not concern this subtree. The model, however, keeps a single storage row for each key; the same key cannot have two different values.

```swift
import SwiftUI

struct ReaderScreen: View {
    var body: some View {
        HStack {
            ArticlePane()
            SidePane()
                .environment(\.textScale, 3)
        }
        .environment(\.textScale, 2)
    }
}
```

In the measurement below, the override is therefore represented with two separate keys: `textScale` carries the upper value, and `paneTextScale` carries the overriding value in the side pane. We write which view reads which by looking at the source; the model cannot find this out by itself.

```swift
// main.swift — an override in a subtree is a separate key in the model
let storage: [String: Storage] = [
    "textScale": .environment,
    "paneTextScale": .environment,
]

let screen = ViewTree(nodes: [
    ViewNode("ReaderScreen", parent: nil, reads: []),
    ViewNode("ArticlePane", parent: "ReaderScreen", reads: ["textScale"]),
    ViewNode("SidePane", parent: "ReaderScreen", reads: ["paneTextScale"]),
], storage: storage)

let recomputes = [
    screen.change("upper-scale-changed", key: "textScale"),
    screen.change("side-scale-changed", key: "paneTextScale"),
].flatMap { $0 }

countCauses(recomputes)
```

```
upper-scale-changed: textScale changed | recomputed ArticlePane environment | skipped ReaderScreen,SidePane
side-scale-changed: paneTextScale changed | recomputed SidePane environment | skipped ReaderScreen,ArticlePane
local: 0
parent: 0
observed: 0
environment: 2
```

A change to the upper value recomputed only `ArticlePane`, and a change to the overriding value recomputed only `SidePane`. Both rows are environment. But it was the two keys we set up, not the measurement, that determined this result: if we had written a `textScale` read for `SidePane` in the tree, the model, not knowing overrides, would have recomputed it on the upper change as well. So the model can **represent** an override, but it cannot **distinguish** one. That an override shadows the upper value in the real framework is documented behavior; it is not something measured in this lesson.

**SN12.** This measurement is only for a representation with two keys. The case where the same key is read with two values in the upper and lower subtree, the case where overriding is nested across several layers, and the case where the overriding value is derived from the upper value were not measured.

## Summary

- An environment value is placed at one point in the tree; a reader view beneath it reads it without touching the views in between.
- When the value changes, only the view that reads it is recomputed and the trigger is environment; an in-between view that does not read it is skipped.
- A system environment value and a custom `@Entry` key produce the same trigger.
- When a property of an object placed in the environment changes, the trigger is observation, not environment; the environment is only the access path.
- An override in a subtree is represented as a separate key in a single-key model; the model cannot distinguish an override.
- This lesson's trigger: **environment**.

## Next Step

At this point all four triggers have been measured, and in every one of them what changed was a value. In the next lesson the screen itself is tied to a value: a path that says which screen will be shown. The navigation stack and its path are a state, and the question of which views a change to that state recomputes is again the same trigger question.
