From a8fef4c28b0bb8242325dfdf07045a12b0cd3a46 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:32:52 -0400 Subject: [PATCH 1/8] Reactivity --- .local.dic | 11 + .../reactivity/derived-state.md | 198 ++++++++++++++++++ .../in-depth-topics/reactivity/index.md | 116 ++++++++++ .../reactivity/outside-world.md | 163 ++++++++++++++ .../in-depth-topics/reactivity/root-state.md | 185 ++++++++++++++++ guides/release/pages.yml | 12 ++ 6 files changed, 685 insertions(+) create mode 100644 guides/release/in-depth-topics/reactivity/derived-state.md create mode 100644 guides/release/in-depth-topics/reactivity/index.md create mode 100644 guides/release/in-depth-topics/reactivity/outside-world.md create mode 100644 guides/release/in-depth-topics/reactivity/root-state.md diff --git a/.local.dic b/.local.dic index 2290e12f4a..947412d6ca 100644 --- a/.local.dic +++ b/.local.dic @@ -52,6 +52,9 @@ debounce declaratively DefinitelyTyped deps +destroyables +destructor +destructors dev draggable dropdown @@ -136,6 +139,9 @@ LSP Mapbox TomTom MDN +memoization +memoize +memoized metaprogramming misspelt mixin @@ -173,6 +179,7 @@ PRs readme readonly recognizers +recomputation recursing Redux relayout @@ -196,9 +203,11 @@ screencasting selectable self-referentiality serverless +Signalium singularize Splattributes SSR +Starbeam stateful subclassed subclasses @@ -218,6 +227,7 @@ synergistically syntaxes tagless TalkBack +TC39 teardown template-lifecycle-dom-and-modifiers templating @@ -237,6 +247,7 @@ typechecker typings UIs un-representable +uncached unordered unsilence unstyled diff --git a/guides/release/in-depth-topics/reactivity/derived-state.md b/guides/release/in-depth-topics/reactivity/derived-state.md new file mode 100644 index 0000000000..028da6716d --- /dev/null +++ b/guides/release/in-depth-topics/reactivity/derived-state.md @@ -0,0 +1,198 @@ +Derived state is the formula layer of the reactive graph: everything computed _from_ [root state](../root-state/). In a healthy Ember application, this is most of your state—and in Ember, it requires no special API at all. An ordinary getter, an ordinary function, an ordinary template expression: if it reads tracked state, it is derived state, and it stays up to date automatically. + +```js +import { tracked } from '@glimmer/tracking'; + +class Search { + @tracked query = ''; + @tracked results = []; + + get hasQuery() { + return this.query.length > 0; + } + + get visibleResults() { + return this.results.filter((result) => !result.hidden); + } + + get summary() { + return this.hasQuery + ? `${this.visibleResults.length} results for “${this.query}”` + : 'Type to search'; + } +} +``` + +There is no decorator on these getters, no list of dependencies, no subscription. `summary` depends on `hasQuery` and `visibleResults`, which depend on `query` and `results`—and the system discovers that graph by itself, by watching what each computation reads while it runs. + +## Derivations Are Lazy + +The most important thing to understand about derived state in Ember: **changing root state does not run your getters.** A write to `@tracked` state only marks the things that consumed it as out of date. The getter runs again when—and only when—something actually reads it. If nothing reads it, it never runs. + +```js +class Search { + @tracked query = ''; + + get normalizedQuery() { + console.log('computing!'); + return this.query.trim().toLowerCase(); + } +} + +let search = new Search(); + +search.query = 'Hello'; +search.query = 'Hello, world'; +search.query = 'Hello, world!'; +// ...nothing is logged. No computation has happened at all. + +search.normalizedQuery; // logs "computing!" — exactly once +``` + +This is _pull-based_ reactivity (described in [Thinking in Reactivity](../)), and it's why you can be generous with derived state. A getter that nothing currently displays costs nothing, no matter how often its inputs change. Ten getters reading the same tracked property add no overhead to writes. Work happens at read time, driven by what the page actually needs. + +The corollary: **never rely on a getter running for its timing.** A derivation may run once, many times, or never; it may run later than you expect or more often than you expect. If you find yourself wanting "run this code _when_ X changes," you are looking for something other than derived state—see [Reactivity and the Outside World](../outside-world/). + +## Derivations Must Be Pure + +A derivation's job is to compute a value from its inputs. It must not _change_ anything—and above all, it must not write to tracked state. This rule is universal across reactive systems (Solid's documentation gives the same warning about memos), and Ember enforces it: writing to a tracked value that has already been read during the current render throws a development-mode error: + +```text +Error: You attempted to update `count`, but it had already been used +previously in the same computation. +``` + +This is sometimes called the _backtracking assertion_: render evaluates your derivations top to bottom, and a write partway through would invalidate output that was already produced—the reactive equivalent of a spreadsheet formula that edits other cells. The fix is never to find a sneakier place for the write; it's to restructure so the write isn't needed: + +```js +// 🛑 Don't: a "derivation" that pushes its result somewhere else +get filteredItems() { + let filtered = this.items.filter((item) => item.matches(this.query)); + this.resultCount = filtered.length; // write inside a read! + return filtered; +} + +// ✅ Do: derive both values independently +get filteredItems() { + return this.items.filter((item) => item.matches(this.query)); +} + +get resultCount() { + return this.filteredItems.length; +} +``` + +Purity is also what makes derived state effortless to test: `new Search()`, set some properties, assert on some getters. No rendering, no waiting, no framework. + +## Caching + +By default, a getter recomputes every time it is read. This surprises people coming from systems whose derivations are memoized by default (Solid's `createMemo`, Signalium's `reactive` functions)—but it's the right default, because most derivations are cheap and a cache has its own costs. Recomputing `this.items.length` is faster than checking whether a cached copy is still valid. + +When a derivation _is_ genuinely expensive—sorting thousands of rows, building a chart's dataset—mark it with `@cached`: + +```js +import { cached, tracked } from '@glimmer/tracking'; + +class Report { + @tracked transactions = []; + + @cached + get sortedByAmount() { + return [...this.transactions].sort((a, b) => b.amount - a.amount); + } +} +``` + +A `@cached` getter remembers its result along with everything it consumed while computing it. Reads return the cached value until one of those consumed inputs is invalidated; then the next read recomputes. (Note what `@cached` does _not_ do: it doesn't compare the new result to the old one. If `transactions` is invalidated but the sorted output happens to come out identical, consumers downstream are still re-evaluated. Some systems—Solid's memos, Signalium—add an equality cutoff here; Ember today does not.) + +Two more good reasons to reach for `@cached`, beyond raw cost: + +- **Stable identity.** An uncached getter that returns a fresh array or object on every read can defeat downstream `===` checks and cause child components to see "new" values that are deep-equal to the old ones. Caching makes the derivation return the _same_ object until its inputs actually change. +- **Once-per-change semantics.** If a derivation must observably run at most once per change (because it allocates, logs, or is just very hot), `@cached` guarantees that. + +See [Autotracking In-Depth](../../autotracking-in-depth/#toc_caching-of-tracked-properties) for a step-by-step illustration of the caching behavior. + +## Composition: Build Big Formulas from Small Ones + +Because derivations are just getters and functions, they compose the way all JavaScript composes—and the dependency graph follows along. Prefer many small derivations over one large one: + +```js +get activeUsers() { + return this.users.filter((user) => user.isActive); +} + +get activeAdmins() { + return this.activeUsers.filter((user) => user.isAdmin); +} + +get headline() { + return `${this.activeAdmins.length} admins online`; +} +``` + +Each step is independently readable, testable, and reusable—and invalidation stays precise, because each layer only consumes what it actually reads. + +Derivations don't have to live on classes, either. A plain function that reads tracked state is a derivation too, and in template tag files you can use one directly as a helper: + +```gjs +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +function initials(name) { + return name + .split(' ') + .map((part) => part[0]) + .join(''); +} + +export default class Roster extends Component { + +} +``` + +`initials` doesn't read tracked state itself, but it participates in the graph all the same: it's re-evaluated for a person whenever the `name` passed to it is invalidated. Pure functions like this—parameterized derivations—are the most reusable form of derived state. See [Helper Functions](../../../components/helper-functions/) for more. + +For derived state that several components need, the same composition rule applies one level up: put the root state _and_ its derivations together in a class (as in the `Cart` example in [Root State](../root-state/#toc_keep-root-state-private-expose-meaning)) or a [service](../../../services/), and let components consume the finished getters. + +## Thinking in Derivations + +When a new piece of UI state shows up, the order to try is: + +1. **Can it be an expression in the template?** `{{if @isAdmin "superuser"}}` needs no JavaScript at all. +2. **Can it be a getter or pure function?** This covers nearly everything else. +3. **Is it genuinely new information that arrives from outside?** Only then is it [root state](../root-state/). + +A symptom worth watching for: an event handler that updates several tracked properties "to keep them consistent" is almost always storing derivations. Move the consistency into getters, and let the handler write the one fact that actually changed: + +```js +// 🛑 Don't: the handler maintains derived state by hand +selectPlan = (plan) => { + this.selectedPlan = plan; + this.price = plan.monthlyPrice * (this.isAnnual ? 12 : 1); + this.discount = this.isAnnual ? plan.annualDiscount : 0; + this.total = this.price - this.discount; +}; + +// ✅ Do: the handler records one fact; formulas do the rest +selectPlan = (plan) => { + this.selectedPlan = plan; +}; + +get price() { + return this.selectedPlan.monthlyPrice * (this.isAnnual ? 12 : 1); +} + +get discount() { + return this.isAnnual ? this.selectedPlan.annualDiscount : 0; +} + +get total() { + return this.price - this.discount; +} +``` + +In the first version, `total` is only correct if every code path that touches any input remembers to recompute it. In the second, `total` _cannot_ be wrong—toggling `isAnnual` from a completely different part of the app updates it automatically, through code that was written without any knowledge of that future feature. That's the payoff of the formula layer, and it's why "derive, don't sync" is the central habit of reactive programming. diff --git a/guides/release/in-depth-topics/reactivity/index.md b/guides/release/in-depth-topics/reactivity/index.md new file mode 100644 index 0000000000..577f1152aa --- /dev/null +++ b/guides/release/in-depth-topics/reactivity/index.md @@ -0,0 +1,116 @@ +Reactivity is the heart of every modern UI framework: when your data changes, everything computed from that data—including the page itself—updates automatically. You declare _what_ the output should be for any given state, and the system figures out _when_ and _what_ to update. + +You have been using Ember's reactivity system—called _autotracking_—since your first `@tracked` property. This section of the guides goes deeper: not just _how_ to use the APIs, but how to _think_ about reactivity, so that you can design state that stays correct as your application grows. The ideas here are not unique to Ember—systems like [Solid](https://www.solidjs.com/), [Starbeam](https://starbeamjs.com/), [Signalium](https://signalium.dev/), and the [TC39 Signals proposal](https://github.com/tc39/proposal-signals) share the same foundations—so learning them will sharpen how you think about UI state in general. + +## The Spreadsheet Mental Model + +The oldest and best mental model for reactivity is a spreadsheet. + +In a spreadsheet, some cells contain plain _values_ that you type in: `A1 = 5`, `A2 = 10`. Other cells contain _formulas_ that reference those values: `A3 = A1 + A2`. When you change `A1`, you don't tell `A3` to update—it just does. And a formula can reference other formulas, building up a whole graph of computation that stays consistent no matter which value you edit. + +Every reactive system is this spreadsheet, generalized: + +- **Root state** is the cells you type into: the values that change directly, because a user clicked something, a server responded, or time passed. In Ember, root state is what you mark with `@tracked`. +- **Derived state** is the formulas: values computed _from_ root state (or from other derived state). In Ember, derived state is ordinary getters, functions, and template expressions. +- **Outputs** are where the data universe meets the outside world: the rendered DOM, the document title, a chart drawn on a canvas. In Ember, the primary output is your templates—the renderer watches everything your templates read, and updates the DOM when any of it changes. + +Data flows in one direction: root state at the bottom, derivations stacked on top, outputs at the edge. Events from the outside world (clicks, responses, timers) write to root state, and the whole graph above stays consistent automatically. + +```gjs +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { fn } from '@ember/helper'; + +export default class Cart extends Component { + // Root state: the values that change directly + @tracked items = []; + + // Derived state: formulas over root state + get subtotal() { + return this.items.reduce((sum, item) => sum + item.price, 0); + } + + get tax() { + return this.subtotal * 0.08; + } + + get total() { + return this.subtotal + this.tax; + } + + addItem = (item) => { + // Events write to root state; everything else updates on its own + this.items = [...this.items, item]; + }; + + // Output: the rendered page + +} +``` + +Notice the proportions: one tracked property, three getters. This is typical of well-designed reactive code, and it's the most important habit this section of the guides hopes to teach: **most of your state should be derived, and only the irreducible minimum should be root state.** The [Root State](./root-state/) and [Derived State](./derived-state/) chapters develop this in detail. + +## The Two Fundamental Operations + +Underneath every reactive system—autotracking included—are just two operations: + +- **Consume**: when a value is _read_ while something reactive is being computed (a template rendering, a cached getter evaluating), the system records "this computation used that value." +- **Invalidate** (or _dirty_): when a value is _written_, the system marks every computation that consumed it as out of date. + +That's the whole trick. When Ember renders `{{this.total}}`, it evaluates `total`, which reads `subtotal` and `tax`, which read `items`—and because `items` is tracked, that read is _consumed_. Later, when `addItem` assigns to `this.items`, the write _invalidates_ the rendered output, and Ember schedules a re-render of exactly the parts of the DOM that consumed it. + +Two properties of this design are worth internalizing: + +**Dependencies are discovered at runtime, every time.** You never declare what a getter depends on; the system records what it _actually reads_ during each evaluation. This means even conditional dependencies just work: + +```js +get displayName() { + return this.useNickname ? this.nickname : this.fullName; +} +``` + +While `useNickname` is `false`, changes to `nickname` don't invalidate anything—`displayName` never read it. If `useNickname` becomes `true`, the next evaluation reads `nickname`, and from then on changes to it propagate. The dependency graph rewires itself on every run. + +**Tracking is synchronous.** The system can only observe reads that happen _during_ a reactive computation. If you read tracked state in a callback that runs later—after an `await`, inside a `setTimeout`—that read happens outside any tracking context, and nothing is consumed. This is rarely a problem in practice (templates, getters, and helpers are all synchronous), but it explains a class of "why didn't this update?" bugs. The [Reactivity and the Outside World](./outside-world/) chapter covers the boundary in detail. + +## Pull, Not Push + +There are two ways a reactive system can respond to a write: + +- A **push**-based system eagerly re-runs every affected computation the moment a value changes. +- A **pull**-based (or _lazy_) system merely marks affected computations as out of date, and recomputes them only when someone actually needs their result. + +Autotracking is pull-based. When you write to a tracked property, _no user code runs_. Your getters are not re-evaluated; nothing is recomputed. The write just bumps an internal revision counter and lets the renderer know that something it consumed is out of date. Later—asynchronously, but before the browser paints—the renderer re-evaluates the expressions in your templates and updates the DOM. + +This has practical consequences that are easy to feel but hard to place if you don't know the model: + +- **Writes are cheap, and they coalesce.** Setting ten tracked properties in one event handler causes one re-render, not ten. You don't need to batch updates yourself. +- **Unused state is free.** A derived value that nothing currently reads is never computed, no matter how often its inputs change. Work scales with what's _on the page_, not with what's _in your data_. +- **Reading state never observes a half-applied update.** Because derivations run on demand rather than in a notification cascade, there is no window where `tax` has updated but `subtotal` hasn't. Your data is always internally consistent. +- **There is no "re-run this code when X changes" primitive.** In a push-based system you might reach for an _effect_ for that. Ember deliberately doesn't offer one; the [Reactivity and the Outside World](./outside-world/) chapter explains why, and what to do instead. + +## The Same Ideas, Elsewhere + +If you've used other reactive systems—or read about "signals," which is what the broader JavaScript ecosystem calls these ideas—here is how the vocabulary maps: + +| Concept | Ember | Solid | Starbeam | Signalium | +| ------------- | ----------------------------- | ---------------------- | ----------------- | -------------------- | +| Root state | `@tracked` | `createSignal` | cells, `reactive` | `signal` | +| Derived state | getters, `@cached` | functions, `createMemo` | formulas, getters | `reactive` functions | +| Outputs | templates, modifiers | JSX, `createEffect` | renderer, resources | watchers, relays | + +The differences between these systems are mostly at the edges—when computation happens (Solid's effects are eager; Ember and Signalium are lazy) and how outputs are expressed (Solid hands you `createEffect`; Ember and Starbeam route effects through the renderer and lifecycle-managed constructs). The core—consume on read, invalidate on write, derive everything you can—is the same everywhere. + +The rest of this section works through each layer of the model: + +- [Root State](./root-state/) — what should (and should not) be root state, and how to design it. +- [Derived State](./derived-state/) — formulas: laziness, purity, caching, and composition. +- [Reactivity and the Outside World](./outside-world/) — outputs, side effects, async, and the edges of the graph. + +For the mechanics of `@tracked` itself—updating, custom classes, arrays and objects, `@cached`—see [Autotracking In-Depth](../autotracking-in-depth/). diff --git a/guides/release/in-depth-topics/reactivity/outside-world.md b/guides/release/in-depth-topics/reactivity/outside-world.md new file mode 100644 index 0000000000..9e2c66282e --- /dev/null +++ b/guides/release/in-depth-topics/reactivity/outside-world.md @@ -0,0 +1,163 @@ +The reactive graph—[root state](../root-state/) at the bottom, [derived state](../derived-state/) above it—is a closed, pure world: values in, values out, no surprises. But applications exist to affect the world outside that graph: paint pixels, play audio, talk to servers, listen to sockets. This chapter is about the edges of the graph—how data gets in, how it gets out, and why Ember draws those edges where it does. + +The shape to keep in mind: + +- **Inputs** write to root state: event handlers, response callbacks, subscription messages, timers. +- **Outputs** read the graph and act on the world: the renderer first among them. + +Everything in between stays pure. + +## Rendering Is the Effect + +In some reactive systems, you wire outputs yourself with an _effect_ primitive—Solid's `createEffect`, for example, re-runs a function whenever the reactive values it read change. If you ask "where is Ember's `createEffect`?", the first answer is: **you've been using it all along—it's the renderer.** + +A template is a declaration of effects: every `{{expression}}`, every attribute binding is a tiny "when this value changes, update that DOM" rule. The renderer plays the same role Signalium assigns to its _watchers_: it is the exit point of the graph, the thing that actively pulls on your derivations and pushes the results into the world. You write the pure part; the framework owns the part that touches the world—batching, scheduling before paint, and updating only the DOM whose inputs actually changed. + +## Why There Is No `createEffect` + +The second answer is that the omission is deliberate, and the reasons are instructive—you can find most of them stated as _warnings_ in the documentation of frameworks that have effects: + +- **Effects are eager.** An effect must re-run on every change to its inputs, whether or not anyone needs the result, breaking the lazy, pull-based economics that make the rest of the system cheap. (Signalium, which is lazy like Ember, allows watchers but tells you never to create them inside reactive code.) +- **Effect ordering is undefined.** When one change triggers several effects, the execution order is unspecified—Solid's documentation says plainly that it "should not be relied upon." Correctness that depends on effect order is a latent bug. +- **Effects that write state are a trap.** The most common effect mistake is using one to _sync_ state: "when X changes, update Y." Now Y is stale until the effect runs, can disagree with X, and if the effect's write triggers another effect, you have a cascade or an infinite loop. Solid's own guides answer this with "use `createMemo` instead"—that is: _derive, don't sync_. In Ember, [the getter was the answer all along](../derived-state/), and the backtracking assertion makes write-during-derivation a loud error rather than a quiet bug. +- **Almost every "effect" is something more specific.** Look closely at real `createEffect` calls and you find: derived values (should be getters), DOM manipulation (should be scoped to an element), and lifecycle-bound processes like subscriptions (should be tied to an owner's lifetime, with cleanup). Ember provides each of those as a dedicated, managed construct instead of one general escape hatch. + +## Managed Outputs: Effects with a Lifetime + +When you do need to act on the world, Ember's tools all share one design: the effect is attached to something with a _lifetime_, runs with cleanup, and re-runs through the same autotracking as everything else. + +**Modifiers** are effects scoped to a DOM element. They run when the element is rendered, re-run (after cleanup) when tracked state they consumed changes, and clean up when the element goes away: + +```js {data-filename="app/modifiers/draw-chart.js"} +import { modifier } from 'ember-modifier'; +import Chart from 'chart.js/auto'; + +export default modifier((element, [data]) => { + let chart = new Chart(element, { type: 'bar', data }); + + return () => chart.destroy(); +}); +``` + +```gjs +import drawChart from 'my-app/modifiers/draw-chart'; + + +``` + +This is "re-run when `@chartData` changes"—an effect—but bounded: it cannot run before there's an element, cannot leak after the element is gone, and declares in the template exactly where its impact lands. See [Template Lifecycle, DOM, and Modifiers](../../../components/template-lifecycle-dom-and-modifiers/). + +**Destroyables** cover lifecycle-bound work with no element. Anything with an owner—components, services, helpers—can pair setup with guaranteed teardown via `registerDestructor` from [`@ember/destroyable`](https://api.emberjs.com/ember/release/modules/@ember%2Fdestroyable): + +```js {data-filename="app/services/clock.js"} +import Service from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { registerDestructor } from '@ember/destroyable'; + +export default class ClockService extends Service { + @tracked now = new Date(); + + constructor(...args) { + super(...args); + + let timer = setInterval(() => { + this.now = new Date(); + }, 1000); + + registerDestructor(this, () => clearInterval(timer)); + } +} +``` + +Any getter, anywhere in the app, can now derive from `clock.now`—"seconds remaining," "is the store open," a formatted timestamp—and every one of them updates each second, while the actual side effect (one interval, one cleanup) stays in one place. + +This setup-plus-cleanup-plus-reactive-state package is what Starbeam calls a _resource_, and its docs model the same example almost identically (a `Clock` resource whose `setInterval` is started in setup and cleared in cleanup). In the Ember ecosystem the [ember-resources](https://github.com/NullVoxPopuli/ember-resources) library offers resources as values you can use right in components and templates; a built-in equivalent is an active area of design. A service with a destructor, as above, is the no-dependencies version of the pattern. + +## Inputs: Writing into the Graph from Outside + +The inverse direction needs no special machinery at all. Code running outside the graph—event handlers, socket callbacks, timers, promise resolutions—simply writes to root state, and the graph takes it from there: + +```js +this.socket.addEventListener('message', (event) => { + this.lastMessage = JSON.parse(event.data); // a tracked property +}); +``` + +Writes from the outside are always safe. The backtracking assertion only restricts writes _during_ a reactive computation (inside getters and templates); an event callback runs outside any computation, so it can write as much as it likes, and all the writes coalesce into a single re-render. + +The clock service above is the full input pattern in miniature: an external process (the interval) feeds the graph through one tracked write, and cleanup is bound to a lifetime. Subscriptions, `ResizeObserver`s, `BroadcastChannel`s—they all take this shape: **subscribe with cleanup; on each notification, write root state; derive everything else.** + +## Async: Tracking Stops at `await` + +Tracking contexts are synchronous. The system records reads that happen _while_ a template expression or cached getter is computing—and a computation, in JavaScript, ends at the first `await`. Code after an `await` (or inside `setTimeout`, or a `.then()` callback) runs later, outside the computation that started it, so nothing it reads is consumed: + +```js +// 🛑 The renderer cannot see through this +get userName() { + return fetch(`/users/${this.userId}`).then((r) => r.json()); // a Promise, not a name +} +``` + +Derivations must be synchronous. The reactive way to handle async work follows from the input rule above: the _request_ is a side effect; its _progress_ is root state. Run the effect at a lifetime boundary, and write each phase of it into tracked properties: + +```js {data-filename="app/components/profile.gjs"} +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +class Request { + @tracked status = 'pending'; + @tracked value = null; + @tracked error = null; + + get isPending() { + return this.status === 'pending'; + } + + constructor(promise) { + promise.then( + (value) => { + this.value = value; + this.status = 'resolved'; + }, + (error) => { + this.error = error; + this.status = 'rejected'; + } + ); + } +} + +export default class Profile extends Component { + user = new Request( + fetch(`/users/${this.args.userId}`).then((response) => response.json()) + ); + + +} +``` + +Once async state is _data_, it stops being a special case: "show a spinner while pending" is just another derivation, the same `{{#if}}` as anything else. This "reactive promise" shape—status, value, and error as reactive fields—is where the whole ecosystem has converged: Signalium builds it in as `ReactivePromise` (with `isPending`, `isResolved`, `value`, and friends), Solid's resources and Starbeam's resources wrap it in lifetime management, and in Ember it's available today via libraries like [ember-resources](https://github.com/NullVoxPopuli/ember-resources) and [WarpDrive](https://docs.warp-drive.io/)'s request state, or in a dozen lines of your own, as above. + +Note one limitation of the example as written: the request is created in a field initializer, so it captures `userId` once and won't re-fetch if the argument changes. Re-running an effect when its reactive inputs change is exactly the job of the managed constructs from earlier—a modifier (if there's a sensible element) or a resource. That's the general rule of this chapter closing the loop: **when an effect needs to respond to the graph, give it a lifetime the framework manages; when the world needs to update the graph, write root state.** + +## Choosing the Right Edge + +| You want to… | Reach for | +| --------------------------------------------------------- | ---------------------------------------------------- | +| Update the page when state changes | A template expression—that's the renderer's job | +| Compute a value from other values | A [getter or function](../derived-state/) | +| Manipulate a DOM element when state changes | A [modifier](../../../components/template-lifecycle-dom-and-modifiers/) | +| Start a process and clean it up with its owner | `registerDestructor` (or a resource) | +| Feed external events into the app | Write tracked state from the callback | +| Track an async operation | Store its status/value/error as root state | +| Run arbitrary code "whenever X changes" | Reconsider—it's almost always one of the above | diff --git a/guides/release/in-depth-topics/reactivity/root-state.md b/guides/release/in-depth-topics/reactivity/root-state.md new file mode 100644 index 0000000000..b444ea705a --- /dev/null +++ b/guides/release/in-depth-topics/reactivity/root-state.md @@ -0,0 +1,185 @@ +Root state is the foundation of the reactive graph: the values that change _directly_, rather than being computed from something else. Everything else in your application—derived values, rendered DOM—is a consequence of root state. That makes designing your root state the highest-leverage decision in managing UI state: get it right, and the rest of your code becomes formulas that can't fall out of sync. + +In Ember, you create root state by marking storage as tracked: + +```js +import { tracked } from '@glimmer/tracking'; + +class Draft { + @tracked title = ''; + @tracked body = ''; +} +``` + +A write to a tracked property is the _only_ way anything changes in a reactive application. Every update you see on screen traces back to some event handler, timer, or response callback assigning to root state. + +## What Qualifies as Root State + +A value belongs in root state only if _both_ of these are true: + +1. It changes over time, in response to the outside world (user input, network responses, timers). +2. It cannot be computed from other state. + +The second rule is the one that gets violated in practice, and it's worth being strict about. Ask of every tracked property: _could I compute this instead?_ + +```js +class Cart { + @tracked items = []; + + // 🛑 Don't: this is derived state stored as root state + @tracked itemCount = 0; + + // ✅ Do: derive it + get itemCount() { + return this.items.length; + } +} +``` + +The tracked `itemCount` looks harmless, but it creates a second source of truth. Now every code path that changes `items` must also remember to update `itemCount`, forever. Once two copies of the truth exist, they _will_ disagree eventually, and that bug class simply doesn't exist for the getter. Storing derived values is sometimes pitched as an optimization—but autotracking already recomputes lazily and only when inputs change, so the optimization is usually imaginary. (When a derivation really is expensive, [cache it](../derived-state/#toc_caching)—don't promote it to root state.) + +A useful instinct from the [Solid](https://docs.solidjs.com/concepts/intro-to-reactivity) and [Starbeam](https://starbeamjs.com/) communities: a well-factored reactive application has surprisingly _little_ root state. A search page might have exactly two root values—the query string and the raw results—while everything else on screen (filtered lists, counts, empty-state flags, disabled buttons) is derived. + +## Writes, Equality, and Dirtying + +When does a write actually invalidate things? In Ember today, the answer is simple: _every_ assignment to a tracked property dirties it, even if you assign the value it already had. + +```js +this.count = this.count; // still invalidates everything that consumed `count` +``` + +Consumers re-evaluate, and the renderer re-checks the DOM it produced (the DOM itself won't change if the final values are equal, but the recomputation happens). Other systems make the opposite choice: Solid's signals and Signalium's `signal()` compare the new value to the old one—with `===` by default—and do nothing if they're equal, cutting invalidation off at the source. + +
+
+
+
Zoey says...
+
+ RFC #1071 (accepted, not yet released) brings configurable equality to Ember: @tracked({ equals: (a, b) => a === b }), and a tracked() function for creating reactive values outside of classes. Until it ships, you can get equality-checking behavior by guarding the write yourself: if (next !== this.count) this.count = next; +
+
+ +
+
+ +Because dirtying is per-property, the _granularity_ of your root state determines the granularity of updates. Three tracked properties invalidate independently; one tracked object replaced wholesale invalidates everything that read any part of it. Neither is wrong—but it's a dial you control. + +## Mutable Data: Replace or Track the Collection + +`@tracked` tracks _assignments to the property_, not mutations inside the value. Pushing into a plain array or setting a key on a plain object is invisible to the system: + +```js +class ShoppingList { + @tracked items = []; + + addItem(item) { + this.items.push(item); // 🛑 not tracked — nothing updates + } +} +``` + +You have two good options. The first is to treat values as immutable and _replace_ them, which keeps all change flowing through the one tracked write: + +```js +addItem = (item) => { + this.items = [...this.items, item]; +}; +``` + +The second is to use a tracked collection from [`@ember/reactive/collections`](https://api.emberjs.com/ember/release/modules/@ember%2Freactive%2Fcollections), which tracks reads and writes of its _contents_ at fine granularity: + +```js +import { trackedArray } from '@ember/reactive/collections'; + +class ShoppingList { + items = trackedArray([]); + + addItem = (item) => { + this.items.push(item); // ✅ tracked + }; +} +``` + +Note that the property itself no longer needs `@tracked`—the collection carries its own reactivity, and the property is never reassigned. Tracked collections are shallow: `trackedObject`'s properties are tracked, but objects stored _inside_ it are ordinary objects unless you wrap them too. See [Autotracking In-Depth](../../autotracking-in-depth/#toc_plain-old-javascript-objects-pojos) for the full tour of `trackedObject`, `trackedArray`, `trackedMap`, and `trackedSet`. + +Prefer replacement for small values and value-like data; prefer tracked collections when a collection is long-lived, large, or mutated from many places. + +## Keep Root State Private, Expose Meaning + +Root state is an implementation detail. The code that _uses_ your state shouldn't know (or care) which parts are stored and which are computed. A pattern used heavily in Starbeam's documentation—and just as good in Ember—is to keep reactive storage private and expose a domain-shaped public API: + +```js +import { trackedMap } from '@ember/reactive/collections'; + +export class Cart { + // Root state: private, mutable, reactive + #items = trackedMap(); + + // Public API: domain-shaped, read-only, derived + get items() { + return [...this.#items.values()]; + } + + get isEmpty() { + return this.#items.size === 0; + } + + get total() { + return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0); + } + + // Mutations: named after what they mean, not how they're stored + add(product, quantity = 1) { + this.#items.set(product.id, { ...product, quantity }); + } + + remove(productId) { + this.#items.delete(productId); + } +} +``` + +Consumers read `cart.total` and call `cart.add(product)`—ordinary JavaScript, fully reactive, with no way to corrupt the internal storage. If you later change how items are stored, nothing outside the class notices. Classes like this need no framework machinery at all; they work in components, services, route models, and plain unit tests alike. + +## Where Root State Lives + +Root state needs an owner—something whose lifetime matches the state's lifetime: + +- **Component state** belongs on the component (or on plain classes the component creates). It's created and thrown away with the component instance. See [Component State and Actions](../../../components/component-state-and-actions/). +- **Application-wide state** belongs in a [service](../../../services/), which lives as long as the application and can be injected anywhere. +- **URL-driven state** (the current route, query params) belongs in the router—reach for it via route models and query params rather than copying it into tracked properties. + +One place root state should generally _not_ live is module scope: + +```js +// 🛑 Avoid in apps +import { trackedObject } from '@ember/reactive/collections'; + +export const settings = trackedObject({ theme: 'light' }); +``` + +It works—reactivity doesn't care where storage lives—but modules are only evaluated once, so this state silently persists across acceptance and integration tests, leaking one test's writes into the next. State that would be module-scoped almost always wants to be a service, which is created and destroyed per application instance (and per test). The exception is demos and scratch code, where module state's brevity is the point. + +## Root State Is Not a Cache for Someone Else's Truth + +A special case of "could I compute this instead?" arises with _data that arrives from elsewhere_—arguments passed to your component, records from your data layer, the current route. The reactive system already tracks these. Copying them into your own tracked properties creates the synchronization problem again: + +```js +// 🛑 Don't: copying an argument into root state +class UserCard extends Component { + @tracked displayName = this.args.user.name; +} +``` + +This captures `name` once and goes stale when the argument changes. Deriving stays current automatically: + +```js +// ✅ Do: derive from the argument +class UserCard extends Component { + get displayName() { + return this.args.user.name ?? 'Anonymous'; + } +} +``` + +If you genuinely need "the argument, until the user edits it locally" (a form draft, for example), that's _new_ root state whose initial value happens to come from elsewhere—create it explicitly in response to a user action, not by mirroring the argument on every change. The [Patterns for Components](../../patterns-for-components/) guide shows several shapes of this. diff --git a/guides/release/pages.yml b/guides/release/pages.yml index 1601039df8..41bb8722eb 100644 --- a/guides/release/pages.yml +++ b/guides/release/pages.yml @@ -151,6 +151,18 @@ pages: - title: "Autotracking In-Depth" url: "autotracking-in-depth" + - title: "Reactivity" + url: "reactivity" + isAdvanced: true + pages: + - title: "Thinking in Reactivity" + url: "index" + - title: "Root State" + url: "root-state" + - title: "Derived State" + url: "derived-state" + - title: "Reactivity and the Outside World" + url: "outside-world" - title: "Patterns for Components" url: "patterns-for-components" - title: "Patterns for Actions" From f93c0cf7766aeaf26a2ed7e440c1c5b91f8fbc54 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:09:35 -0400 Subject: [PATCH 2/8] wip --- .local.dic | 1 + .../in-depth-topics/autotracking-in-depth.md | 50 +++- .../reactivity/derived-state.md | 132 ++++++++-- .../in-depth-topics/reactivity/index.md | 180 +++++++++---- .../reactivity/outside-world.md | 165 +++++++++--- .../in-depth-topics/reactivity/root-state.md | 247 ++++++++++++++++-- 6 files changed, 631 insertions(+), 144 deletions(-) diff --git a/.local.dic b/.local.dic index 947412d6ca..5eec512a69 100644 --- a/.local.dic +++ b/.local.dic @@ -203,6 +203,7 @@ screencasting selectable self-referentiality serverless +shorthands Signalium singularize Splattributes diff --git a/guides/release/in-depth-topics/autotracking-in-depth.md b/guides/release/in-depth-topics/autotracking-in-depth.md index b62bddb98d..c64882e097 100644 --- a/guides/release/in-depth-topics/autotracking-in-depth.md +++ b/guides/release/in-depth-topics/autotracking-in-depth.md @@ -1,7 +1,9 @@ Autotracking is how Ember's _reactivity_ model works - how it decides what to -rerender, and when. This guide covers tracking in more depth, including how it -can be used in various types of classes, and how it interacts with arrays and -POJOs. +rerender, and when. This guide covers the mechanics of tracking in more depth, +including how it can be used in various types of classes, and how it interacts +with arrays and POJOs. For the concepts behind the system - and guidance on +designing your application's state - see +[Thinking in Reactivity](../reactivity/). ## Autotracking Basics @@ -195,7 +197,10 @@ export default class HelloComponent extends Component { This will also trigger a rerender. No matter where the update occurs, updating a tracked property will let Ember know to rerender any affected portion of the -app. +app. Writing to tracked state from callbacks like this is the standard way +data from the outside world enters Ember's reactivity system - see +[Reactivity and the Outside World](../reactivity/outside-world/) for more on +this pattern. ### Tracking Through Methods @@ -266,6 +271,8 @@ Tracked properties can also be applied to your own custom classes, and used within your components and routes: ```js {data-filename=src/utils/person.js} +import { tracked } from '@glimmer/tracking'; + export default class Person { @tracked title; @tracked name; @@ -314,7 +321,7 @@ export default class ApplicationRouteComponent extends Component { ``` As long as the properties are tracked, and accessed when rendering the template -directly or indirectly, everything should update as expected +directly or indirectly, everything should update as expected. ### Plain Old JavaScript Objects (POJOs) @@ -341,6 +348,10 @@ All property reading and writing on this object is automatically tracked. `obj.c.somethingDeeper = 5` would not be tracked unless you've also made sure that the contents of `obj.c` is itself another `trackedObject`. +For guidance on when to reach for a tracked collection versus replacing a +value wholesale, see +[Root State](../reactivity/root-state/#toc_mutable-data-replace-or-track-the-collection). + #### Arrays @@ -362,6 +373,27 @@ class ShoppingList { `trackedArray` supports all the normal native `Array` methods, ensuring that their reads and writes are tracked. +#### Maps and Sets + +`trackedMap`, `trackedSet`, `trackedWeakMap`, and `trackedWeakSet` round out +the collections, following the same pattern: + +```js +import { trackedMap } from '@ember/reactive/collections'; + +class Cart { + quantities = trackedMap(); + + add(productId) { + let current = this.quantities.get(productId) ?? 0; + this.quantities.set(productId, current + 1); + } +} +``` + +Each collection supports all the methods of its native counterpart, ensuring +that their reads and writes are tracked. + ## Caching of tracked properties In contrast to computed properties from pre-Octane, tracked properties are not @@ -403,7 +435,7 @@ getter is very expensive, however, you will want to cache the value and retrieve it when the dependencies haven't changed. You want to recompute only if a dependency has been updated. -Ember's [@cached decorator](https://api.emberjs.com/ember/6.8/functions/@glimmer%2Ftracking/cached) lets +Ember's [@cached decorator](https://api.emberjs.com/ember/release/functions/@glimmer%2Ftracking/cached) lets you cache (or "memoize") a getter by simply marking it as `@cached`. With this in mind, let's introduce caching to `aspectRatio`: @@ -443,6 +475,10 @@ console.log(count); // 2 From the value of `count`, we see that, this time, `aspectRatio` was calculated only twice. -In general, you should avoid using @cached unless you have confirmed that the getter you are decorating is computationally expensive, since @cached adds a small amount of overhead to the getter. +In general, you should avoid using `@cached` unless you have confirmed that +the getter you are decorating is computationally expensive, since `@cached` +adds a small amount of overhead to the getter. Beyond performance, there are +a couple of situations where caching changes behavior in useful ways - see +[Derived State](../reactivity/derived-state/#toc_caching) for details. diff --git a/guides/release/in-depth-topics/reactivity/derived-state.md b/guides/release/in-depth-topics/reactivity/derived-state.md index 028da6716d..0d038cf7b5 100644 --- a/guides/release/in-depth-topics/reactivity/derived-state.md +++ b/guides/release/in-depth-topics/reactivity/derived-state.md @@ -1,4 +1,9 @@ -Derived state is the formula layer of the reactive graph: everything computed _from_ [root state](../root-state/). In a healthy Ember application, this is most of your state—and in Ember, it requires no special API at all. An ordinary getter, an ordinary function, an ordinary template expression: if it reads tracked state, it is derived state, and it stays up to date automatically. +Derived state is the formula layer of the reactive graph: everything computed +_from_ [root state](../root-state/). In a healthy Ember application, this is +most of your state - and in Ember, it requires no special API at all. An +ordinary getter, an ordinary function, an ordinary template expression: if it +reads tracked state, it is derived state, and it stays up to date +automatically. ```js import { tracked } from '@glimmer/tracking'; @@ -23,11 +28,18 @@ class Search { } ``` -There is no decorator on these getters, no list of dependencies, no subscription. `summary` depends on `hasQuery` and `visibleResults`, which depend on `query` and `results`—and the system discovers that graph by itself, by watching what each computation reads while it runs. +There is no decorator on these getters, no list of dependencies, no +subscription. `summary` depends on `hasQuery` and `visibleResults`, which +depend on `query` and `results` - and the system discovers that graph by +itself, by watching what each computation reads while it runs. ## Derivations Are Lazy -The most important thing to understand about derived state in Ember: **changing root state does not run your getters.** A write to `@tracked` state only marks the things that consumed it as out of date. The getter runs again when—and only when—something actually reads it. If nothing reads it, it never runs. +The most important thing to understand about derived state in Ember: +**changing root state does not run your getters.** A write to `@tracked` state +only marks the things that consumed it as out of date. The getter runs again +when - and only when - something actually reads it. If nothing reads it, it +never runs. ```js class Search { @@ -46,23 +58,41 @@ search.query = 'Hello, world'; search.query = 'Hello, world!'; // ...nothing is logged. No computation has happened at all. -search.normalizedQuery; // logs "computing!" — exactly once +search.normalizedQuery; // logs "computing!" - exactly once ``` -This is _pull-based_ reactivity (described in [Thinking in Reactivity](../)), and it's why you can be generous with derived state. A getter that nothing currently displays costs nothing, no matter how often its inputs change. Ten getters reading the same tracked property add no overhead to writes. Work happens at read time, driven by what the page actually needs. +This is _pull-based_ reactivity, described in +[Thinking in Reactivity](../), and it's why you can be generous with derived +state. A getter that nothing currently displays costs nothing, no matter how +often its inputs change. Ten getters reading the same tracked property add no +overhead to writes. Work happens at read time, driven by what the page +actually needs. -The corollary: **never rely on a getter running for its timing.** A derivation may run once, many times, or never; it may run later than you expect or more often than you expect. If you find yourself wanting "run this code _when_ X changes," you are looking for something other than derived state—see [Reactivity and the Outside World](../outside-world/). +The corollary: **never rely on a getter running for its timing.** A derivation +may run once, many times, or never; it may run later than you expect or more +often than you expect. If you find yourself wanting "run this code _when_ X +changes," you are looking for something other than derived state - see +[Reactivity and the Outside World](../outside-world/). ## Derivations Must Be Pure -A derivation's job is to compute a value from its inputs. It must not _change_ anything—and above all, it must not write to tracked state. This rule is universal across reactive systems (Solid's documentation gives the same warning about memos), and Ember enforces it: writing to a tracked value that has already been read during the current render throws a development-mode error: +A derivation's job is to compute a value from its inputs. It must not _change_ +anything - and above all, it must not write to tracked state. This rule is +universal across reactive systems (Solid's documentation gives the same +warning about memos), and Ember enforces it: writing to a tracked value that +has already been read during the current render throws a development-mode +error: ```text Error: You attempted to update `count`, but it had already been used previously in the same computation. ``` -This is sometimes called the _backtracking assertion_: render evaluates your derivations top to bottom, and a write partway through would invalidate output that was already produced—the reactive equivalent of a spreadsheet formula that edits other cells. The fix is never to find a sneakier place for the write; it's to restructure so the write isn't needed: +This is sometimes called the _backtracking assertion_: render evaluates your +derivations top to bottom, and a write partway through would invalidate output +that was already produced - the reactive equivalent of a spreadsheet formula +that edits other cells. The fix is never to find a sneakier place for the +write; it's to restructure so the write isn't needed: ```js // 🛑 Don't: a "derivation" that pushes its result somewhere else @@ -82,13 +112,21 @@ get resultCount() { } ``` -Purity is also what makes derived state effortless to test: `new Search()`, set some properties, assert on some getters. No rendering, no waiting, no framework. +Purity is also what makes derived state effortless to test: `new Search()`, +set some properties, assert on some getters. No rendering, no waiting, no +framework. ## Caching -By default, a getter recomputes every time it is read. This surprises people coming from systems whose derivations are memoized by default (Solid's `createMemo`, Signalium's `reactive` functions)—but it's the right default, because most derivations are cheap and a cache has its own costs. Recomputing `this.items.length` is faster than checking whether a cached copy is still valid. +By default, a getter recomputes every time it is read. This surprises people +coming from systems whose derivations are memoized by default, like Solid's +`createMemo` and Signalium's `reactive` functions - but it's the right +default, because most derivations are cheap and a cache has its own costs. +Recomputing `this.items.length` is faster than checking whether a cached copy +is still valid. -When a derivation _is_ genuinely expensive—sorting thousands of rows, building a chart's dataset—mark it with `@cached`: +When a derivation _is_ genuinely expensive - sorting thousands of rows, +building a chart's dataset - mark it with `@cached`: ```js import { cached, tracked } from '@glimmer/tracking'; @@ -103,18 +141,33 @@ class Report { } ``` -A `@cached` getter remembers its result along with everything it consumed while computing it. Reads return the cached value until one of those consumed inputs is invalidated; then the next read recomputes. (Note what `@cached` does _not_ do: it doesn't compare the new result to the old one. If `transactions` is invalidated but the sorted output happens to come out identical, consumers downstream are still re-evaluated. Some systems—Solid's memos, Signalium—add an equality cutoff here; Ember today does not.) +A `@cached` getter remembers its result along with everything it consumed +while computing it. Reads return the cached value until one of those consumed +inputs is invalidated; then the next read recomputes. -Two more good reasons to reach for `@cached`, beyond raw cost: +Note what `@cached` does _not_ do: it doesn't compare the new result to the +old one. If `transactions` is invalidated but the sorted output happens to +come out identical, consumers downstream are still re-evaluated. Some systems +(Solid's memos, Signalium) add an equality cutoff here; Ember today does not. -- **Stable identity.** An uncached getter that returns a fresh array or object on every read can defeat downstream `===` checks and cause child components to see "new" values that are deep-equal to the old ones. Caching makes the derivation return the _same_ object until its inputs actually change. -- **Once-per-change semantics.** If a derivation must observably run at most once per change (because it allocates, logs, or is just very hot), `@cached` guarantees that. +Beyond raw cost, there are two more good reasons to reach for `@cached`: -See [Autotracking In-Depth](../../autotracking-in-depth/#toc_caching-of-tracked-properties) for a step-by-step illustration of the caching behavior. +- **Stable identity.** An uncached getter that returns a fresh array or object + on every read can defeat downstream `===` checks and cause child components + to see "new" values that are deep-equal to the old ones. Caching makes the + derivation return the _same_ object until its inputs actually change. +- **Once-per-change semantics.** If a derivation must observably run at most + once per change (because it allocates, logs, or is just very hot), `@cached` + guarantees that. + +See [Autotracking In-Depth](../../autotracking-in-depth/#toc_caching-of-tracked-properties) +for a step-by-step illustration of the caching behavior. ## Composition: Build Big Formulas from Small Ones -Because derivations are just getters and functions, they compose the way all JavaScript composes—and the dependency graph follows along. Prefer many small derivations over one large one: +Because derivations are just getters and functions, they compose the way all +JavaScript composes - and the dependency graph follows along. Prefer many +small derivations over one large one: ```js get activeUsers() { @@ -130,11 +183,14 @@ get headline() { } ``` -Each step is independently readable, testable, and reusable—and invalidation stays precise, because each layer only consumes what it actually reads. +Each step is independently readable, testable, and reusable - and invalidation +stays precise, because each layer only consumes what it actually reads. -Derivations don't have to live on classes, either. A plain function that reads tracked state is a derivation too, and in template tag files you can use one directly as a helper: +Derivations don't have to live on classes, either. A plain function that reads +tracked state is a derivation too, and in template tag files you can use one +directly as a helper: -```gjs +```gjs {data-filename=app/components/roster.gjs} import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; @@ -154,19 +210,34 @@ export default class Roster extends Component { } ``` -`initials` doesn't read tracked state itself, but it participates in the graph all the same: it's re-evaluated for a person whenever the `name` passed to it is invalidated. Pure functions like this—parameterized derivations—are the most reusable form of derived state. See [Helper Functions](../../../components/helper-functions/) for more. +`initials` doesn't read tracked state itself, but it participates in the graph +all the same: it's re-evaluated for a person whenever the `name` passed to it +is invalidated. Pure functions like this - parameterized derivations - are the +most reusable form of derived state. See +[Helper Functions](../../../components/helper-functions/) for more. -For derived state that several components need, the same composition rule applies one level up: put the root state _and_ its derivations together in a class (as in the `Cart` example in [Root State](../root-state/#toc_keep-root-state-private-expose-meaning)) or a [service](../../../services/), and let components consume the finished getters. +For derived state that several components need, the same composition rule +applies one level up: put the root state _and_ its derivations together in a +class (as in the `Cart` example in +[Root State](../root-state/#toc_keep-root-state-private-expose-meaning)) or a +[service](../../../services/), and let components consume the finished +getters. ## Thinking in Derivations -When a new piece of UI state shows up, the order to try is: +When a new piece of UI state shows up, try these options in order: -1. **Can it be an expression in the template?** `{{if @isAdmin "superuser"}}` needs no JavaScript at all. -2. **Can it be a getter or pure function?** This covers nearly everything else. -3. **Is it genuinely new information that arrives from outside?** Only then is it [root state](../root-state/). +1. **Can it be an expression in the template?** + `{{if @isAdmin "superuser"}}` needs no JavaScript at all. +2. **Can it be a getter or pure function?** This covers nearly everything + else. +3. **Is it genuinely new information that arrives from outside?** Only then is + it [root state](../root-state/). -A symptom worth watching for: an event handler that updates several tracked properties "to keep them consistent" is almost always storing derivations. Move the consistency into getters, and let the handler write the one fact that actually changed: +A symptom worth watching for: an event handler that updates several tracked +properties "to keep them consistent" is almost always storing derivations. +Move the consistency into getters, and let the handler write the one fact that +actually changed: ```js // 🛑 Don't: the handler maintains derived state by hand @@ -195,4 +266,9 @@ get total() { } ``` -In the first version, `total` is only correct if every code path that touches any input remembers to recompute it. In the second, `total` _cannot_ be wrong—toggling `isAnnual` from a completely different part of the app updates it automatically, through code that was written without any knowledge of that future feature. That's the payoff of the formula layer, and it's why "derive, don't sync" is the central habit of reactive programming. +In the first version, `total` is only correct if every code path that touches +any input remembers to recompute it. In the second, `total` _cannot_ be wrong. +Toggling `isAnnual` from a completely different part of the app updates it +automatically, through code that was written without any knowledge of that +future feature. That's the payoff of the formula layer, and it's why "derive, +don't sync" is the central habit of reactive programming. diff --git a/guides/release/in-depth-topics/reactivity/index.md b/guides/release/in-depth-topics/reactivity/index.md index 577f1152aa..e780d46fac 100644 --- a/guides/release/in-depth-topics/reactivity/index.md +++ b/guides/release/in-depth-topics/reactivity/index.md @@ -1,22 +1,49 @@ -Reactivity is the heart of every modern UI framework: when your data changes, everything computed from that data—including the page itself—updates automatically. You declare _what_ the output should be for any given state, and the system figures out _when_ and _what_ to update. - -You have been using Ember's reactivity system—called _autotracking_—since your first `@tracked` property. This section of the guides goes deeper: not just _how_ to use the APIs, but how to _think_ about reactivity, so that you can design state that stays correct as your application grows. The ideas here are not unique to Ember—systems like [Solid](https://www.solidjs.com/), [Starbeam](https://starbeamjs.com/), [Signalium](https://signalium.dev/), and the [TC39 Signals proposal](https://github.com/tc39/proposal-signals) share the same foundations—so learning them will sharpen how you think about UI state in general. +Reactivity is at the heart of every modern UI framework: when your data +changes, everything computed from that data - including the page itself - +updates automatically. You declare what the output should be for any given +state, and the framework figures out when and what to update. + +You have been using Ember's reactivity system, called _autotracking_, since +your first `@tracked` property. The guides in this section go deeper than the +API: they cover how to _think_ about reactivity, so that you can design state +that stays correct as your application grows. These ideas are not unique to +Ember - systems like [Solid](https://www.solidjs.com/), +[Starbeam](https://starbeamjs.com/), [Signalium](https://signalium.dev/), and +the [TC39 Signals proposal](https://github.com/tc39/proposal-signals) are +built on the same foundations - so learning them will help you reason about UI +state in any framework. ## The Spreadsheet Mental Model The oldest and best mental model for reactivity is a spreadsheet. -In a spreadsheet, some cells contain plain _values_ that you type in: `A1 = 5`, `A2 = 10`. Other cells contain _formulas_ that reference those values: `A3 = A1 + A2`. When you change `A1`, you don't tell `A3` to update—it just does. And a formula can reference other formulas, building up a whole graph of computation that stays consistent no matter which value you edit. +In a spreadsheet, some cells contain plain _values_ that you type in: +`A1 = 5`, `A2 = 10`. Other cells contain _formulas_ that reference those +values: `A3 = A1 + A2`. When you change `A1`, you don't tell `A3` to update - +it just does. A formula can also reference other formulas, building up a whole +graph of computation that stays consistent no matter which value you edit. Every reactive system is this spreadsheet, generalized: -- **Root state** is the cells you type into: the values that change directly, because a user clicked something, a server responded, or time passed. In Ember, root state is what you mark with `@tracked`. -- **Derived state** is the formulas: values computed _from_ root state (or from other derived state). In Ember, derived state is ordinary getters, functions, and template expressions. -- **Outputs** are where the data universe meets the outside world: the rendered DOM, the document title, a chart drawn on a canvas. In Ember, the primary output is your templates—the renderer watches everything your templates read, and updates the DOM when any of it changes. - -Data flows in one direction: root state at the bottom, derivations stacked on top, outputs at the edge. Events from the outside world (clicks, responses, timers) write to root state, and the whole graph above stays consistent automatically. - -```gjs +- **Root state** is the cells you type into: the values that change directly, + because a user clicked something, a server responded, or time passed. In + Ember, root state is what you mark with `@tracked`. +- **Derived state** is the formulas: values computed _from_ root state, or + from other derived state. In Ember, derived state is ordinary getters, + functions, and template expressions. +- **Outputs** are where your data meets the outside world: the rendered DOM, + the document title, a chart drawn on a canvas. In Ember, the primary output + is your templates. The renderer watches everything your templates read, and + updates the DOM when any of it changes. + +Data flows in one direction: root state at the bottom, derivations stacked on +top, outputs at the edge. Events from the outside world (clicks, responses, +timers) write to root state, and everything above stays consistent +automatically. + +Here is what all three layers look like in a single component: + +```gjs {data-filename=app/components/cart.gjs} import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { on } from '@ember/modifier'; @@ -54,20 +81,36 @@ export default class Cart extends Component { } ``` -Notice the proportions: one tracked property, three getters. This is typical of well-designed reactive code, and it's the most important habit this section of the guides hopes to teach: **most of your state should be derived, and only the irreducible minimum should be root state.** The [Root State](./root-state/) and [Derived State](./derived-state/) chapters develop this in detail. +Notice the proportions in this component: one tracked property, three getters. +This is typical of well-designed reactive code, and it is the most important +habit this section hopes to teach: **most of your state should be derived, and +only the irreducible minimum should be root state.** The +[Root State](./root-state/) and [Derived State](./derived-state/) guides +develop this idea in detail. ## The Two Fundamental Operations -Underneath every reactive system—autotracking included—are just two operations: +Underneath every reactive system - autotracking included - are just two +operations: -- **Consume**: when a value is _read_ while something reactive is being computed (a template rendering, a cached getter evaluating), the system records "this computation used that value." -- **Invalidate** (or _dirty_): when a value is _written_, the system marks every computation that consumed it as out of date. +- **Consume**: when a value is _read_ while something reactive is being + computed (a template rendering, a cached getter evaluating), the system + records that the computation used that value. +- **Invalidate** (or _dirty_): when a value is _written_, the system marks + every computation that consumed it as out of date. -That's the whole trick. When Ember renders `{{this.total}}`, it evaluates `total`, which reads `subtotal` and `tax`, which read `items`—and because `items` is tracked, that read is _consumed_. Later, when `addItem` assigns to `this.items`, the write _invalidates_ the rendered output, and Ember schedules a re-render of exactly the parts of the DOM that consumed it. +That's the whole trick! When Ember renders `{{this.total}}` in the component +above, it evaluates `total`, which reads `subtotal` and `tax`, which read +`items` - and because `items` is tracked, that read is _consumed_. Later, when +`addItem` assigns to `this.items`, the write _invalidates_ the rendered +output, and Ember schedules a rerender of exactly the parts of the DOM that +consumed it. -Two properties of this design are worth internalizing: +Two properties of this design are worth internalizing. -**Dependencies are discovered at runtime, every time.** You never declare what a getter depends on; the system records what it _actually reads_ during each evaluation. This means even conditional dependencies just work: +First, **dependencies are discovered at runtime, every time.** You never +declare what a getter depends on; the system records what it _actually reads_ +during each evaluation. This means even conditional dependencies just work: ```js get displayName() { @@ -75,42 +118,91 @@ get displayName() { } ``` -While `useNickname` is `false`, changes to `nickname` don't invalidate anything—`displayName` never read it. If `useNickname` becomes `true`, the next evaluation reads `nickname`, and from then on changes to it propagate. The dependency graph rewires itself on every run. +While `useNickname` is `false`, changes to `nickname` don't invalidate +anything, because `displayName` never read it. If `useNickname` becomes +`true`, the next evaluation reads `nickname`, and from then on changes to it +propagate. The dependency graph rewires itself on every run. -**Tracking is synchronous.** The system can only observe reads that happen _during_ a reactive computation. If you read tracked state in a callback that runs later—after an `await`, inside a `setTimeout`—that read happens outside any tracking context, and nothing is consumed. This is rarely a problem in practice (templates, getters, and helpers are all synchronous), but it explains a class of "why didn't this update?" bugs. The [Reactivity and the Outside World](./outside-world/) chapter covers the boundary in detail. +Second, **tracking is synchronous.** The system can only observe reads that +happen _while_ a reactive computation is running. If you read tracked state in +a callback that runs later - after an `await`, or inside a `setTimeout` - that +read happens outside any tracking context, and nothing is consumed. This is +rarely a problem in practice, since templates, getters, and helpers are all +synchronous, but it explains a whole class of "why didn't this update?" bugs. +The [Reactivity and the Outside World](./outside-world/) guide covers this +boundary in detail. ## Pull, Not Push There are two ways a reactive system can respond to a write: -- A **push**-based system eagerly re-runs every affected computation the moment a value changes. -- A **pull**-based (or _lazy_) system merely marks affected computations as out of date, and recomputes them only when someone actually needs their result. - -Autotracking is pull-based. When you write to a tracked property, _no user code runs_. Your getters are not re-evaluated; nothing is recomputed. The write just bumps an internal revision counter and lets the renderer know that something it consumed is out of date. Later—asynchronously, but before the browser paints—the renderer re-evaluates the expressions in your templates and updates the DOM. - -This has practical consequences that are easy to feel but hard to place if you don't know the model: - -- **Writes are cheap, and they coalesce.** Setting ten tracked properties in one event handler causes one re-render, not ten. You don't need to batch updates yourself. -- **Unused state is free.** A derived value that nothing currently reads is never computed, no matter how often its inputs change. Work scales with what's _on the page_, not with what's _in your data_. -- **Reading state never observes a half-applied update.** Because derivations run on demand rather than in a notification cascade, there is no window where `tax` has updated but `subtotal` hasn't. Your data is always internally consistent. -- **There is no "re-run this code when X changes" primitive.** In a push-based system you might reach for an _effect_ for that. Ember deliberately doesn't offer one; the [Reactivity and the Outside World](./outside-world/) chapter explains why, and what to do instead. +- A **push**-based system eagerly re-runs every affected computation the + moment a value changes. +- A **pull**-based (or _lazy_) system merely marks affected computations as + out of date, and recomputes them only when someone actually needs their + result. + +Autotracking is pull-based. When you write to a tracked property, _no user +code runs_. Your getters are not re-evaluated; nothing is recomputed. The +write just lets the renderer know that something it consumed is out of date. +Later - asynchronously, but before the browser paints - the renderer +re-evaluates the expressions in your templates and updates the DOM. + +This has practical consequences that are easy to feel but hard to place if you +don't know the model: + +- **Writes are cheap, and they coalesce.** Setting ten tracked properties in + one event handler causes one rerender, not ten. You don't need to batch + updates yourself. +- **Unused state is free.** A derived value that nothing currently reads is + never computed, no matter how often its inputs change. Work scales with + what's on the page, not with what's in your data. +- **Reading state never observes a half-applied update.** Because derivations + run on demand rather than in a notification cascade, there is no window + where `tax` has updated but `subtotal` hasn't. Your data is always + internally consistent. +- **There is no "re-run this code when X changes" primitive.** In a push-based + system you might reach for an _effect_ for that. Ember deliberately doesn't + offer one; the [Reactivity and the Outside World](./outside-world/) guide + explains why, and what to do instead. ## The Same Ideas, Elsewhere -If you've used other reactive systems—or read about "signals," which is what the broader JavaScript ecosystem calls these ideas—here is how the vocabulary maps: - -| Concept | Ember | Solid | Starbeam | Signalium | -| ------------- | ----------------------------- | ---------------------- | ----------------- | -------------------- | -| Root state | `@tracked` | `createSignal` | cells, `reactive` | `signal` | -| Derived state | getters, `@cached` | functions, `createMemo` | formulas, getters | `reactive` functions | -| Outputs | templates, modifiers | JSX, `createEffect` | renderer, resources | watchers, relays | - -The differences between these systems are mostly at the edges—when computation happens (Solid's effects are eager; Ember and Signalium are lazy) and how outputs are expressed (Solid hands you `createEffect`; Ember and Starbeam route effects through the renderer and lifecycle-managed constructs). The core—consume on read, invalidate on write, derive everything you can—is the same everywhere. +If you've used other reactive systems - or read about "signals," which is what +the broader JavaScript ecosystem calls these ideas - here is how the +vocabulary maps: + +| Framework / library | Root state | Derived state | Outputs | +| ------------------- | ----------------------- | ------------------------ | ------------------------ | +| Ember | `@tracked`, `tracked()` | getters, `@cached` | templates (the renderer) | +| Svelte | `$state` | `$derived` | templates, `$effect` | +| Vue | `ref`, `reactive` | `computed` | templates, `watch` | +| Angular | `signal` | `computed` | templates, `effect` | +| Solid | `createSignal` | functions, `createMemo` | JSX, `createEffect` | +| Starbeam | cells, `reactive` | formulas, getters | renderer, resources | +| Signalium | `signal` | `reactive` functions | watchers, relays | + +A note on the comparisons: these tools don't all sit at the same level of +abstraction. Starbeam and Signalium are reactivity libraries rather than full +application frameworks, and Solid - though you can build applications with +it - is significantly lower-level than Ember. It's often described as a +framework for building frameworks, which is why it hands you primitives like +`createEffect` directly, where Ember routes the same job through the renderer +and lifecycle-managed constructs. + +The other differences are mostly at the edges: when computation happens +(Solid's effects are eager; Ember and Signalium are lazy), and how outputs are +expressed. The core - consume on read, invalidate on write, derive everything +you can - is the same everywhere. The rest of this section works through each layer of the model: -- [Root State](./root-state/) — what should (and should not) be root state, and how to design it. -- [Derived State](./derived-state/) — formulas: laziness, purity, caching, and composition. -- [Reactivity and the Outside World](./outside-world/) — outputs, side effects, async, and the edges of the graph. +- [Root State](./root-state/) - what should (and should not) be root state, + and how to design it. +- [Derived State](./derived-state/) - formulas: laziness, purity, caching, and + composition. +- [Reactivity and the Outside World](./outside-world/) - outputs, side + effects, async, and the edges of the graph. -For the mechanics of `@tracked` itself—updating, custom classes, arrays and objects, `@cached`—see [Autotracking In-Depth](../autotracking-in-depth/). +For the mechanics of `@tracked` itself - updating, custom classes, arrays and +objects, `@cached` - see [Autotracking In-Depth](../autotracking-in-depth/). diff --git a/guides/release/in-depth-topics/reactivity/outside-world.md b/guides/release/in-depth-topics/reactivity/outside-world.md index 9e2c66282e..98d57808b7 100644 --- a/guides/release/in-depth-topics/reactivity/outside-world.md +++ b/guides/release/in-depth-topics/reactivity/outside-world.md @@ -1,34 +1,77 @@ -The reactive graph—[root state](../root-state/) at the bottom, [derived state](../derived-state/) above it—is a closed, pure world: values in, values out, no surprises. But applications exist to affect the world outside that graph: paint pixels, play audio, talk to servers, listen to sockets. This chapter is about the edges of the graph—how data gets in, how it gets out, and why Ember draws those edges where it does. +The reactive graph - [root state](../root-state/) at the bottom, +[derived state](../derived-state/) above it - is a closed, pure world: values +in, values out, no surprises. But applications exist to affect the world +outside that graph: paint pixels, play audio, talk to servers, listen to +sockets. This guide is about the edges of the graph - how data gets in, how it +gets out, and why Ember draws those edges where it does. The shape to keep in mind: -- **Inputs** write to root state: event handlers, response callbacks, subscription messages, timers. -- **Outputs** read the graph and act on the world: the renderer first among them. +- **Inputs** write to root state: event handlers, response callbacks, + subscription messages, timers. +- **Outputs** read the graph and act on the world. In Ember, the output is + the renderer; other side effects are _managed effects_, covered below. Everything in between stays pure. ## Rendering Is the Effect -In some reactive systems, you wire outputs yourself with an _effect_ primitive—Solid's `createEffect`, for example, re-runs a function whenever the reactive values it read change. If you ask "where is Ember's `createEffect`?", the first answer is: **you've been using it all along—it's the renderer.** +In some reactive systems, you wire outputs yourself with an _effect_ +primitive. Solid's `createEffect`, for example, re-runs a function whenever +the reactive values it read change. If you ask "where is Ember's +`createEffect`?", the first answer is: **you've been using it all along - it's +the renderer.** -A template is a declaration of effects: every `{{expression}}`, every attribute binding is a tiny "when this value changes, update that DOM" rule. The renderer plays the same role Signalium assigns to its _watchers_: it is the exit point of the graph, the thing that actively pulls on your derivations and pushes the results into the world. You write the pure part; the framework owns the part that touches the world—batching, scheduling before paint, and updating only the DOM whose inputs actually changed. +A template is a declaration of effects: every `{{expression}}` and every +attribute binding is a tiny "when this value changes, update that DOM" rule. +The renderer plays the same role Signalium assigns to its _watchers_: it is +the exit point of the graph, the thing that actively pulls on your derivations +and pushes the results into the world. You write the pure part; the framework +owns the part that touches the world - batching, scheduling before paint, and +updating only the DOM whose inputs actually changed. ## Why There Is No `createEffect` -The second answer is that the omission is deliberate, and the reasons are instructive—you can find most of them stated as _warnings_ in the documentation of frameworks that have effects: - -- **Effects are eager.** An effect must re-run on every change to its inputs, whether or not anyone needs the result, breaking the lazy, pull-based economics that make the rest of the system cheap. (Signalium, which is lazy like Ember, allows watchers but tells you never to create them inside reactive code.) -- **Effect ordering is undefined.** When one change triggers several effects, the execution order is unspecified—Solid's documentation says plainly that it "should not be relied upon." Correctness that depends on effect order is a latent bug. -- **Effects that write state are a trap.** The most common effect mistake is using one to _sync_ state: "when X changes, update Y." Now Y is stale until the effect runs, can disagree with X, and if the effect's write triggers another effect, you have a cascade or an infinite loop. Solid's own guides answer this with "use `createMemo` instead"—that is: _derive, don't sync_. In Ember, [the getter was the answer all along](../derived-state/), and the backtracking assertion makes write-during-derivation a loud error rather than a quiet bug. -- **Almost every "effect" is something more specific.** Look closely at real `createEffect` calls and you find: derived values (should be getters), DOM manipulation (should be scoped to an element), and lifecycle-bound processes like subscriptions (should be tied to an owner's lifetime, with cleanup). Ember provides each of those as a dedicated, managed construct instead of one general escape hatch. - -## Managed Outputs: Effects with a Lifetime - -When you do need to act on the world, Ember's tools all share one design: the effect is attached to something with a _lifetime_, runs with cleanup, and re-runs through the same autotracking as everything else. - -**Modifiers** are effects scoped to a DOM element. They run when the element is rendered, re-run (after cleanup) when tracked state they consumed changes, and clean up when the element goes away: - -```js {data-filename="app/modifiers/draw-chart.js"} +The second answer is that the omission is deliberate, and the reasons are +instructive. You can find most of them stated as _warnings_ in the +documentation of frameworks that have effects: + +- **Effects are eager.** An effect must re-run on every change to its inputs, + whether or not anyone needs the result, breaking the lazy, pull-based + economics that make the rest of the system cheap. (Signalium, which is lazy + like Ember, allows watchers but tells you never to create them inside + reactive code.) +- **Effect ordering is undefined.** When one change triggers several effects, + the execution order is unspecified - Solid's documentation says plainly that + it "should not be relied upon." Correctness that depends on effect order is + a latent bug. +- **Effects that write state are a trap.** The most common effect mistake is + using one to _sync_ state: "when X changes, update Y." Now Y is stale until + the effect runs, can disagree with X, and if the effect's write triggers + another effect, you have a cascade or an infinite loop. Solid's own guides + answer this with "use `createMemo` instead" - that is: _derive, don't sync_. + In Ember, [the getter was the answer all along](../derived-state/), and the + backtracking assertion makes write-during-derivation a loud error rather + than a quiet bug. +- **Almost every "effect" is something more specific.** Look closely at real + `createEffect` calls and you find: derived values (should be getters), DOM + manipulation (should be scoped to an element), and lifecycle-bound processes + like subscriptions (should be tied to an owner's lifetime, with cleanup). + Ember provides each of those as a dedicated, managed construct instead of + one general escape hatch. + +## Managed Effects: Attached to a Lifetime + +Rendering is the only true _output_ in Ember - but it isn't the only side +effect. When you do need to act on the world yourself, Ember's tools all share +one design: the effect is attached to something with a _lifetime_, runs with +cleanup, and re-runs through the same autotracking as everything else. + +**Modifiers** are effects scoped to a DOM element. They run when the element +is rendered, re-run (after cleanup) when tracked state they consumed changes, +and clean up when the element goes away: + +```js {data-filename=app/modifiers/draw-chart.js} import { modifier } from 'ember-modifier'; import Chart from 'chart.js/auto'; @@ -47,11 +90,17 @@ import drawChart from 'my-app/modifiers/draw-chart'; ``` -This is "re-run when `@chartData` changes"—an effect—but bounded: it cannot run before there's an element, cannot leak after the element is gone, and declares in the template exactly where its impact lands. See [Template Lifecycle, DOM, and Modifiers](../../../components/template-lifecycle-dom-and-modifiers/). +This is "re-run when `@chartData` changes" - an effect - but bounded: it +cannot run before there's an element, cannot leak after the element is gone, +and declares in the template exactly where its impact lands. See +[Template Lifecycle, DOM, and Modifiers](../../../components/template-lifecycle-dom-and-modifiers/). -**Destroyables** cover lifecycle-bound work with no element. Anything with an owner—components, services, helpers—can pair setup with guaranteed teardown via `registerDestructor` from [`@ember/destroyable`](https://api.emberjs.com/ember/release/modules/@ember%2Fdestroyable): +**Destroyables** cover lifecycle-bound work with no element. Anything with an +owner - components, services, helpers - can pair setup with guaranteed +teardown via `registerDestructor` from +[`@ember/destroyable`](https://api.emberjs.com/ember/release/modules/@ember%2Fdestroyable): -```js {data-filename="app/services/clock.js"} +```js {data-filename=app/services/clock.js} import Service from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { registerDestructor } from '@ember/destroyable'; @@ -71,13 +120,25 @@ export default class ClockService extends Service { } ``` -Any getter, anywhere in the app, can now derive from `clock.now`—"seconds remaining," "is the store open," a formatted timestamp—and every one of them updates each second, while the actual side effect (one interval, one cleanup) stays in one place. +Any getter, anywhere in the app, can now derive from `clock.now` - "seconds +remaining," "is the store open," a formatted timestamp - and every one of them +updates each second, while the actual side effect (one interval, one cleanup) +stays in one place. -This setup-plus-cleanup-plus-reactive-state package is what Starbeam calls a _resource_, and its docs model the same example almost identically (a `Clock` resource whose `setInterval` is started in setup and cleared in cleanup). In the Ember ecosystem the [ember-resources](https://github.com/NullVoxPopuli/ember-resources) library offers resources as values you can use right in components and templates; a built-in equivalent is an active area of design. A service with a destructor, as above, is the no-dependencies version of the pattern. +This setup-plus-cleanup-plus-reactive-state package is what Starbeam calls a +_resource_, and its docs model the same example almost identically: a `Clock` +resource whose `setInterval` is started in setup and cleared in cleanup. In +the Ember ecosystem, the +[ember-resources](https://github.com/NullVoxPopuli/ember-resources) library +offers resources as values you can use right in components and templates; a +built-in equivalent is an active area of design. A service with a destructor, +as above, is the no-dependencies version of the pattern. ## Inputs: Writing into the Graph from Outside -The inverse direction needs no special machinery at all. Code running outside the graph—event handlers, socket callbacks, timers, promise resolutions—simply writes to root state, and the graph takes it from there: +The inverse direction needs no special machinery at all. Code running outside +the graph - event handlers, socket callbacks, timers, promise resolutions - +simply writes to root state, and the graph takes it from there: ```js this.socket.addEventListener('message', (event) => { @@ -85,13 +146,24 @@ this.socket.addEventListener('message', (event) => { }); ``` -Writes from the outside are always safe. The backtracking assertion only restricts writes _during_ a reactive computation (inside getters and templates); an event callback runs outside any computation, so it can write as much as it likes, and all the writes coalesce into a single re-render. +Writes from the outside are always safe. The backtracking assertion only +restricts writes _during_ a reactive computation (inside getters and +templates). An event callback runs outside any computation, so it can write as +much as it likes, and all the writes coalesce into a single rerender. -The clock service above is the full input pattern in miniature: an external process (the interval) feeds the graph through one tracked write, and cleanup is bound to a lifetime. Subscriptions, `ResizeObserver`s, `BroadcastChannel`s—they all take this shape: **subscribe with cleanup; on each notification, write root state; derive everything else.** +The clock service above is the full input pattern in miniature: an external +process (the interval) feeds the graph through one tracked write, and cleanup +is bound to a lifetime. Subscriptions, `ResizeObserver`s, `BroadcastChannel`s - +they all take this shape: **subscribe with cleanup; on each notification, +write root state; derive everything else.** ## Async: Tracking Stops at `await` -Tracking contexts are synchronous. The system records reads that happen _while_ a template expression or cached getter is computing—and a computation, in JavaScript, ends at the first `await`. Code after an `await` (or inside `setTimeout`, or a `.then()` callback) runs later, outside the computation that started it, so nothing it reads is consumed: +Tracking contexts are synchronous. The system records reads that happen +_while_ a template expression or cached getter is computing - and a +computation, in JavaScript, ends at the first `await`. Code after an `await` +(or inside `setTimeout`, or a `.then()` callback) runs later, outside the +computation that started it, so nothing it reads is consumed: ```js // 🛑 The renderer cannot see through this @@ -100,13 +172,16 @@ get userName() { } ``` -Derivations must be synchronous. The reactive way to handle async work follows from the input rule above: the _request_ is a side effect; its _progress_ is root state. Run the effect at a lifetime boundary, and write each phase of it into tracked properties: +Derivations must be synchronous. The reactive way to handle async work follows +from the input rule above: the _request_ is a side effect; its _progress_ is +root state. Run the effect at a lifetime boundary, and write each phase of it +into tracked properties: -```js {data-filename="app/components/profile.gjs"} +```gjs {data-filename=app/components/profile.gjs} import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; -class Request { +class RequestState { @tracked status = 'pending'; @tracked value = null; @tracked error = null; @@ -130,7 +205,7 @@ class Request { } export default class Profile extends Component { - user = new Request( + user = new RequestState( fetch(`/users/${this.args.userId}`).then((response) => response.json()) ); @@ -146,18 +221,34 @@ export default class Profile extends Component { } ``` -Once async state is _data_, it stops being a special case: "show a spinner while pending" is just another derivation, the same `{{#if}}` as anything else. This "reactive promise" shape—status, value, and error as reactive fields—is where the whole ecosystem has converged: Signalium builds it in as `ReactivePromise` (with `isPending`, `isResolved`, `value`, and friends), Solid's resources and Starbeam's resources wrap it in lifetime management, and in Ember it's available today via libraries like [ember-resources](https://github.com/NullVoxPopuli/ember-resources) and [WarpDrive](https://docs.warp-drive.io/)'s request state, or in a dozen lines of your own, as above. - -Note one limitation of the example as written: the request is created in a field initializer, so it captures `userId` once and won't re-fetch if the argument changes. Re-running an effect when its reactive inputs change is exactly the job of the managed constructs from earlier—a modifier (if there's a sensible element) or a resource. That's the general rule of this chapter closing the loop: **when an effect needs to respond to the graph, give it a lifetime the framework manages; when the world needs to update the graph, write root state.** +Once async state is _data_, it stops being a special case: "show a spinner +while pending" is just another derivation, the same `{{#if}}` as anything +else. This "reactive promise" shape - status, value, and error as reactive +fields - is where the whole ecosystem has converged. Signalium builds it in as +`ReactivePromise` (with `isPending`, `isResolved`, `value`, and friends), +Solid's resources and Starbeam's resources wrap it in lifetime management, and +in Ember it's available today via libraries like +[ember-resources](https://github.com/NullVoxPopuli/ember-resources) and +[WarpDrive](https://docs.warp-drive.io/)'s request state - or in a dozen lines +of your own, as above. + +Note one limitation of the example as written: the request is created in a +field initializer, so it captures `userId` once and won't re-fetch if the +argument changes. Re-running an effect when its reactive inputs change is +exactly the job of the managed constructs from earlier - a modifier (if +there's a sensible element) or a resource. That's the general rule of this +guide closing the loop: **when an effect needs to respond to the graph, give +it a lifetime the framework manages; when the world needs to update the graph, +write root state.** ## Choosing the Right Edge | You want to… | Reach for | | --------------------------------------------------------- | ---------------------------------------------------- | -| Update the page when state changes | A template expression—that's the renderer's job | +| Update the page when state changes | A template expression - that's the renderer's job | | Compute a value from other values | A [getter or function](../derived-state/) | | Manipulate a DOM element when state changes | A [modifier](../../../components/template-lifecycle-dom-and-modifiers/) | | Start a process and clean it up with its owner | `registerDestructor` (or a resource) | | Feed external events into the app | Write tracked state from the callback | | Track an async operation | Store its status/value/error as root state | -| Run arbitrary code "whenever X changes" | Reconsider—it's almost always one of the above | +| Run arbitrary code "whenever X changes" | Reconsider - it's almost always one of the above | diff --git a/guides/release/in-depth-topics/reactivity/root-state.md b/guides/release/in-depth-topics/reactivity/root-state.md index b444ea705a..308aa3fd72 100644 --- a/guides/release/in-depth-topics/reactivity/root-state.md +++ b/guides/release/in-depth-topics/reactivity/root-state.md @@ -1,6 +1,11 @@ -Root state is the foundation of the reactive graph: the values that change _directly_, rather than being computed from something else. Everything else in your application—derived values, rendered DOM—is a consequence of root state. That makes designing your root state the highest-leverage decision in managing UI state: get it right, and the rest of your code becomes formulas that can't fall out of sync. +Root state is the foundation of the reactive graph: the values that change +_directly_, rather than being computed from something else. Everything else in +your application - derived values, rendered DOM - is a consequence of root +state. That makes designing your root state the highest-leverage decision you +make when managing UI state. Get it right, and the rest of your code becomes +formulas that can't fall out of sync. -In Ember, you create root state by marking storage as tracked: +In Ember, you create root state by marking a property as tracked: ```js import { tracked } from '@glimmer/tracking'; @@ -11,16 +16,21 @@ class Draft { } ``` -A write to a tracked property is the _only_ way anything changes in a reactive application. Every update you see on screen traces back to some event handler, timer, or response callback assigning to root state. +A write to a tracked property is the _only_ way anything changes in a reactive +application. Every update you see on screen traces back to some event handler, +timer, or response callback assigning to root state. ## What Qualifies as Root State A value belongs in root state only if _both_ of these are true: -1. It changes over time, in response to the outside world (user input, network responses, timers). +1. It changes over time, in response to the outside world (user input, network + responses, timers). 2. It cannot be computed from other state. -The second rule is the one that gets violated in practice, and it's worth being strict about. Ask of every tracked property: _could I compute this instead?_ +The second rule is the one that gets broken most often in practice, and it's +worth being strict about. For every tracked property, ask yourself: _could I +compute this instead?_ ```js class Cart { @@ -36,49 +46,118 @@ class Cart { } ``` -The tracked `itemCount` looks harmless, but it creates a second source of truth. Now every code path that changes `items` must also remember to update `itemCount`, forever. Once two copies of the truth exist, they _will_ disagree eventually, and that bug class simply doesn't exist for the getter. Storing derived values is sometimes pitched as an optimization—but autotracking already recomputes lazily and only when inputs change, so the optimization is usually imaginary. (When a derivation really is expensive, [cache it](../derived-state/#toc_caching)—don't promote it to root state.) - -A useful instinct from the [Solid](https://docs.solidjs.com/concepts/intro-to-reactivity) and [Starbeam](https://starbeamjs.com/) communities: a well-factored reactive application has surprisingly _little_ root state. A search page might have exactly two root values—the query string and the raw results—while everything else on screen (filtered lists, counts, empty-state flags, disabled buttons) is derived. +The tracked `itemCount` looks harmless, but it creates a second source of +truth. Every code path that changes `items` must now also remember to update +`itemCount`, forever. Once two copies of the truth exist, they will eventually +disagree - and that whole class of bug simply doesn't exist for the getter. + +Storing derived values is sometimes pitched as an optimization. However, +autotracking already recomputes lazily, and only when inputs change, so the +optimization is usually imaginary. When a derivation really is expensive, you +can [cache it](../derived-state/#toc_caching) instead of promoting it to root +state. + +A useful instinct from the +[Solid](https://docs.solidjs.com/concepts/intro-to-reactivity) and +[Starbeam](https://starbeamjs.com/) communities: a well-factored reactive +application has surprisingly _little_ root state. A search page might have +exactly two root values - the query string and the raw results - while +everything else on screen (filtered lists, counts, empty-state flags, disabled +buttons) is derived. ## Writes, Equality, and Dirtying -When does a write actually invalidate things? In Ember today, the answer is simple: _every_ assignment to a tracked property dirties it, even if you assign the value it already had. +When does a write actually invalidate things? In Ember today, the answer is +simple: _every_ assignment to a tracked property dirties it, even if you +assign the value it already had. ```js this.count = this.count; // still invalidates everything that consumed `count` ``` -Consumers re-evaluate, and the renderer re-checks the DOM it produced (the DOM itself won't change if the final values are equal, but the recomputation happens). Other systems make the opposite choice: Solid's signals and Signalium's `signal()` compare the new value to the old one—with `===` by default—and do nothing if they're equal, cutting invalidation off at the source. +Consumers re-evaluate, and the renderer re-checks the DOM it produced. The DOM +itself won't change if the final values are equal, but the recomputation +happens. Other systems make the opposite choice: Solid's signals compare the +new value to the old one - `===` by default - and do nothing if they're equal, +cutting invalidation off at the source. Signalium's `signal()` behaves +similarly. + +[RFC #1071](https://github.com/emberjs/rfcs/pull/1071) brings that dial to +Ember. The decorator accepts an options form, `@tracked({ equals })`, that +skips invalidation entirely when the old and new values are equal: + +```js +import { tracked } from '@glimmer/tracking'; + +class Player { + @tracked({ equals: (a, b) => a === b }) score = 0; + + reset = () => { + this.score = 0; // if score was already 0, nothing invalidates + }; +} +``` + +Without options, `@tracked` keeps its historical always-dirty behavior, so +existing code is unaffected. + +When is custom equality worth it? The default is fine for most state - a +write usually happens because something actually changed. Custom equality +pays off when writes frequently _don't_ change the value: + +- **High-frequency events that usually land on the same value.** Writing the + current scroll direction (`'up'` or `'down'`) on every scroll event, or the + current breakpoint name on every resize: the event fires constantly, but + the value only changes at the moment of reversal or crossing. +- **Data that is re-fetched but rarely different.** Polling a server returns + a fresh object every time. By reference it is always "new"; by content it + is almost always the same as last time. +- **Value-like objects.** Dates, durations, coordinates: two distinct + instances can represent the same value. Comparing by content - for example, + `(a, b) => a.getTime() === b.getTime()` for dates - reflects what the value + _means_ rather than where it lives in memory. + +In each of these cases, without an equality check every write invalidates +every consumer, and the renderer re-evaluates everything downstream just to +conclude that nothing changed. An equality check cuts that work off at the +root. The flip side: the check itself runs on every write, so for values that +really do change on most writes, the default is the cheaper choice.
Zoey says...
- RFC #1071 (accepted, not yet released) brings configurable equality to Ember: @tracked({ equals: (a, b) => a === b }), and a tracked() function for creating reactive values outside of classes. Until it ships, you can get equality-checking behavior by guarding the write yourself: if (next !== this.count) this.count = next; + The @tracked({ equals }) form and the tracked() function described later on this page come from RFC #1071, which is implemented but has not yet shipped in a stable Ember release. Until it ships, you can get equality-checking behavior by guarding the write yourself: if (next !== this.count) this.count = next;
-Because dirtying is per-property, the _granularity_ of your root state determines the granularity of updates. Three tracked properties invalidate independently; one tracked object replaced wholesale invalidates everything that read any part of it. Neither is wrong—but it's a dial you control. +Because dirtying is per-property, the _granularity_ of your root state +determines the granularity of updates. Three tracked properties invalidate +independently; one tracked object replaced wholesale invalidates everything +that read any part of it. Neither is wrong - but it's a dial you control. ## Mutable Data: Replace or Track the Collection -`@tracked` tracks _assignments to the property_, not mutations inside the value. Pushing into a plain array or setting a key on a plain object is invisible to the system: +`@tracked` tracks _assignments to the property_, not mutations inside the +value. Pushing into a plain array or setting a key on a plain object is +invisible to the system: ```js class ShoppingList { @tracked items = []; addItem(item) { - this.items.push(item); // 🛑 not tracked — nothing updates + this.items.push(item); // 🛑 not tracked - nothing updates } } ``` -You have two good options. The first is to treat values as immutable and _replace_ them, which keeps all change flowing through the one tracked write: +You have two good options here. The first is to treat values as immutable and +_replace_ them, which keeps all change flowing through the one tracked write: ```js addItem = (item) => { @@ -86,7 +165,9 @@ addItem = (item) => { }; ``` -The second is to use a tracked collection from [`@ember/reactive/collections`](https://api.emberjs.com/ember/release/modules/@ember%2Freactive%2Fcollections), which tracks reads and writes of its _contents_ at fine granularity: +The second is to use a tracked collection from +[`@ember/reactive/collections`](https://api.emberjs.com/ember/release/modules/@ember%2Freactive%2Fcollections), +which tracks reads and writes of its _contents_ at fine granularity: ```js import { trackedArray } from '@ember/reactive/collections'; @@ -100,13 +181,26 @@ class ShoppingList { } ``` -Note that the property itself no longer needs `@tracked`—the collection carries its own reactivity, and the property is never reassigned. Tracked collections are shallow: `trackedObject`'s properties are tracked, but objects stored _inside_ it are ordinary objects unless you wrap them too. See [Autotracking In-Depth](../../autotracking-in-depth/#toc_plain-old-javascript-objects-pojos) for the full tour of `trackedObject`, `trackedArray`, `trackedMap`, and `trackedSet`. +Note that the property itself no longer needs `@tracked`. The collection +carries its own reactivity, and the property is never reassigned. Two details +worth knowing: the collection functions _copy_ the data you pass in, so +mutating the tracked collection never mutates the original; and tracked +collections are shallow - `trackedObject`'s properties are tracked, but +objects stored _inside_ it are ordinary objects unless you wrap them too. See +[Autotracking In-Depth](../../autotracking-in-depth/#toc_plain-old-javascript-objects-pojos) +for the full tour of `trackedObject`, `trackedArray`, `trackedMap`, and +`trackedSet`. -Prefer replacement for small values and value-like data; prefer tracked collections when a collection is long-lived, large, or mutated from many places. +Prefer replacement for small values and value-like data. Prefer tracked +collections when a collection is long-lived, large, or mutated from many +places. ## Keep Root State Private, Expose Meaning -Root state is an implementation detail. The code that _uses_ your state shouldn't know (or care) which parts are stored and which are computed. A pattern used heavily in Starbeam's documentation—and just as good in Ember—is to keep reactive storage private and expose a domain-shaped public API: +Root state is an implementation detail. The code that _uses_ your state +shouldn't know (or care) which parts are stored and which are computed. A +pattern used heavily in Starbeam's documentation - and just as good in Ember - +is to keep reactive storage private and expose a domain-shaped public API: ```js import { trackedMap } from '@ember/reactive/collections'; @@ -139,15 +233,97 @@ export class Cart { } ``` -Consumers read `cart.total` and call `cart.add(product)`—ordinary JavaScript, fully reactive, with no way to corrupt the internal storage. If you later change how items are stored, nothing outside the class notices. Classes like this need no framework machinery at all; they work in components, services, route models, and plain unit tests alike. +Consumers read `cart.total` and call `cart.add(product)` - ordinary +JavaScript, fully reactive, with no way to corrupt the internal storage. If +you later change how items are stored, nothing outside the class notices. +Classes like this need no framework machinery at all; they work in components, +services, route models, and plain unit tests alike. + +## Reactive Values Without Classes + +[RFC #1071](https://github.com/emberjs/rfcs/pull/1071) also overloads +`tracked` to work as a plain function. Called with a value instead of applied +as a decorator, it returns a standalone reactive value - root state that isn't +attached to any class: + +```js +import { tracked } from '@glimmer/tracking'; + +const count = tracked(0); + +count.value; // reading consumes, like any tracked property +count.value = 1; // writing invalidates consumers +``` + +Unlike the decorator, a standalone value checks equality by default (using +`Object.is`), so assigning the value it already holds invalidates nothing. You +can pass your own comparison with `tracked(initial, { equals })` - useful in +exactly the situations described in +[Writes, Equality, and Dirtying](#toc_writes-equality-and-dirtying) above. For +example, a poll that returns a fresh object every time: + +```js +import { tracked } from '@glimmer/tracking'; + +const serverStatus = tracked( + { state: 'ok', pendingJobs: 0 }, + { + equals: (a, b) => + a.state === b.state && a.pendingJobs === b.pendingJobs, + } +); + +// Each response is a brand-new object, so the default `Object.is` +// would treat every poll as a change. With `equals`, consumers only +// invalidate when the contents actually changed. +serverStatus.value = await fetchStatus(); +``` + +Beyond `.value` there are function shorthands: `get()` and `set(value)`, plus +`update((current) => next)` - which writes based on the current value +_without_ consuming it - and `freeze()`, which prevents all further writes. + +For application code, classes with `@tracked` remain the primary tool. The +function form fills the gaps a decorator can't reach: function-based helpers +and modifiers, tests that want a reactive value without ceremony, and demos. +It also pairs well with the private-storage pattern above, as a truly private +reactive field: + +```js +import { tracked } from '@glimmer/tracking'; + +export class Toggle { + #state = tracked(false); + + get isOn() { + return this.#state.value; + } + + toggle = () => { + this.#state.value = !this.#state.value; + }; +} +``` + +The function form is also a good mental model for the decorator itself: you +can think of each `@tracked` property as syntactic sugar over one of these +values - one per property, per instance - where reading the property reads +`.value` and assigning it writes `.value` (with equality checking turned off, +for historical compatibility). ## Where Root State Lives -Root state needs an owner—something whose lifetime matches the state's lifetime: +Root state needs an owner - something whose lifetime matches the state's +lifetime: -- **Component state** belongs on the component (or on plain classes the component creates). It's created and thrown away with the component instance. See [Component State and Actions](../../../components/component-state-and-actions/). -- **Application-wide state** belongs in a [service](../../../services/), which lives as long as the application and can be injected anywhere. -- **URL-driven state** (the current route, query params) belongs in the router—reach for it via route models and query params rather than copying it into tracked properties. +- **Component state** belongs on the component, or on plain classes the + component creates. It's created and thrown away with the component instance. + See [Component State and Actions](../../../components/component-state-and-actions/). +- **Application-wide state** belongs in a [service](../../../services/), which + lives as long as the application and can be injected anywhere. +- **URL-driven state** (the current route, query params) belongs in the + router. Reach for it via route models and query params rather than copying + it into tracked properties. One place root state should generally _not_ live is module scope: @@ -158,11 +334,20 @@ import { trackedObject } from '@ember/reactive/collections'; export const settings = trackedObject({ theme: 'light' }); ``` -It works—reactivity doesn't care where storage lives—but modules are only evaluated once, so this state silently persists across acceptance and integration tests, leaking one test's writes into the next. State that would be module-scoped almost always wants to be a service, which is created and destroyed per application instance (and per test). The exception is demos and scratch code, where module state's brevity is the point. +It works - reactivity doesn't care where storage lives - but modules are only +evaluated once, so this state silently persists across acceptance and +integration tests, leaking one test's writes into the next. State that would +be module-scoped almost always wants to be a service, which is created and +destroyed per application instance (and per test). The exception is demos and +scratch code, where module state's brevity is the point. ## Root State Is Not a Cache for Someone Else's Truth -A special case of "could I compute this instead?" arises with _data that arrives from elsewhere_—arguments passed to your component, records from your data layer, the current route. The reactive system already tracks these. Copying them into your own tracked properties creates the synchronization problem again: +A special case of "could I compute this instead?" arises with _data that +arrives from elsewhere_: arguments passed to your component, records from your +data layer, the current route. The reactive system already tracks these. +Copying them into your own tracked properties creates the synchronization +problem all over again: ```js // 🛑 Don't: copying an argument into root state @@ -171,7 +356,8 @@ class UserCard extends Component { } ``` -This captures `name` once and goes stale when the argument changes. Deriving stays current automatically: +This captures `name` once and goes stale when the argument changes. Deriving +stays current automatically: ```js // ✅ Do: derive from the argument @@ -182,4 +368,9 @@ class UserCard extends Component { } ``` -If you genuinely need "the argument, until the user edits it locally" (a form draft, for example), that's _new_ root state whose initial value happens to come from elsewhere—create it explicitly in response to a user action, not by mirroring the argument on every change. The [Patterns for Components](../../patterns-for-components/) guide shows several shapes of this. +If you genuinely need "the argument, until the user edits it locally" (a form +draft, for example), that's _new_ root state whose initial value happens to +come from elsewhere. Create it explicitly in response to a user action, not by +mirroring the argument on every change. The +[Patterns for Components](../../patterns-for-components/) guide shows several +shapes of this. From 8a844773e3a7541f31c0f343959d9bf7c14222df Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:38:15 -0400 Subject: [PATCH 3/8] Up --- .local.dic | 1 + .../reactivity/derived-state.md | 164 ++++++++++++++++-- .../in-depth-topics/reactivity/index.md | 31 ++-- .../in-depth-topics/reactivity/root-state.md | 88 +++++++++- 4 files changed, 247 insertions(+), 37 deletions(-) diff --git a/.local.dic b/.local.dic index 5eec512a69..cc54fe679f 100644 --- a/.local.dic +++ b/.local.dic @@ -172,6 +172,7 @@ pre-transition pre-transition preload prepend +poller prepended presentational Presentational diff --git a/guides/release/in-depth-topics/reactivity/derived-state.md b/guides/release/in-depth-topics/reactivity/derived-state.md index 0d038cf7b5..11f5b60b43 100644 --- a/guides/release/in-depth-topics/reactivity/derived-state.md +++ b/guides/release/in-depth-topics/reactivity/derived-state.md @@ -1,6 +1,6 @@ -Derived state is the formula layer of the reactive graph: everything computed -_from_ [root state](../root-state/). In a healthy Ember application, this is -most of your state - and in Ember, it requires no special API at all. An +Derived state is everything computed _from_ [root state](../root-state/). In +a healthy Ember application, this is most of your state - and in Ember, it +requires no special API at all. An ordinary getter, an ordinary function, an ordinary template expression: if it reads tracked state, it is derived state, and it stays up to date automatically. @@ -90,8 +90,7 @@ previously in the same computation. This is sometimes called the _backtracking assertion_: render evaluates your derivations top to bottom, and a write partway through would invalidate output -that was already produced - the reactive equivalent of a spreadsheet formula -that edits other cells. The fix is never to find a sneakier place for the +that was already produced. The fix is never to find a sneakier place for the write; it's to restructure so the write isn't needed: ```js @@ -163,7 +162,7 @@ Beyond raw cost, there are two more good reasons to reach for `@cached`: See [Autotracking In-Depth](../../autotracking-in-depth/#toc_caching-of-tracked-properties) for a step-by-step illustration of the caching behavior. -## Composition: Build Big Formulas from Small Ones +## Composition: Build Big Derivations from Small Ones Because derivations are just getters and functions, they compose the way all JavaScript composes - and the dependency graph follows along. Prefer many @@ -186,9 +185,18 @@ get headline() { Each step is independently readable, testable, and reusable - and invalidation stays precise, because each layer only consumes what it actually reads. -Derivations don't have to live on classes, either. A plain function that reads -tracked state is a derivation too, and in template tag files you can use one -directly as a helper: +For derived state that several components need, the same composition rule +applies one level up: put the root state _and_ its derivations together in a +class (as in the `Cart` example in +[Root State](../root-state/#toc_keep-root-state-private-expose-meaning)) or a +[service](../../../services/), and let components consume the finished +getters. + +## Derivations Outside of Classes + +Derivations don't have to live on classes. A plain function that reads tracked +state is a derivation too, and in template tag files you can use one directly +as a helper: ```gjs {data-filename=app/components/roster.gjs} import Component from '@glimmer/component'; @@ -216,12 +224,134 @@ is invalidated. Pure functions like this - parameterized derivations - are the most reusable form of derived state. See [Helper Functions](../../../components/helper-functions/) for more. -For derived state that several components need, the same composition rule -applies one level up: put the root state _and_ its derivations together in a -class (as in the `Cart` example in -[Root State](../root-state/#toc_keep-root-state-private-expose-meaning)) or a -[service](../../../services/), and let components consume the finished -getters. +The same idea scales up to module scope. A function that takes reactive data +as arguments can be shared across your whole application: + +```js {data-filename=app/utils/cart-math.js} +export function subtotal(items) { + return items.reduce((sum, item) => sum + item.price, 0); +} +``` + +Wherever this runs during a reactive computation - a template, a getter, +another function - reading `items` entangles the caller with that data, and +the result stays live. Note the contrast with module-scoped _state_, which +[should generally be avoided](../root-state/#toc_where-root-state-lives): a +derivation function holds no state of its own, so sharing it at module scope +is always safe. + +## Deferring Consumption + +Reading a tracked value consumes it _right now_. Both of Ember's derivation +tools - getters and functions - work by _deferring_ that read: nothing runs +when they're defined, only when someone asks for the result. + +You've been deferring with getters all along; it's the default style when +working with classes. A getter's body runs when the property is read, so its +tracked reads are consumed by whoever is reading - the template, another +getter - at exactly the moment they matter: + +```js +class Profile { + @tracked name = 'zoey'; + + // Defining this reads (and consumes) nothing... + get displayName() { + return this.name.toUpperCase(); + } +} + +let profile = new Profile(); + +// ...consumption happens here, in the reader's context +profile.displayName; +``` + +Sometimes you need the same deferral for a value you're handing to someone +else, somewhere a getter can't reach - like a constructor argument. That's +the job of a plain function, usually an arrow function: wrap the read, and +nothing is consumed until the function is called. + +```js +let name = this.person.name; // reads (and consumes) immediately +let getName = () => this.person.name; // reads nothing - yet +``` + +Arrow functions capture `this` and their surrounding scope, which makes them +_portable_ derivations: you can hand one to another object, and every call +re-reads the current value from wherever the state actually lives. + +This matters most in constructors and field initializers, because they run +exactly once, when an object is created. Any tracked value they read is +captured as a one-time snapshot - the same trap as +[copying arguments into root state](../root-state/#toc_root-state-is-not-a-cache-for-someone-elses-truth). +Passing functions instead keeps the connection live: + +```js +// 🛑 Don't: values are read once, at construction, and go stale +class Filter { + constructor(items, query) { + this.items = items; + this.query = query; + } + + get results() { + return this.items.filter((item) => item.matches(this.query)); + } +} + +export default class SearchResults extends Component { + filter = new Filter(this.args.items, this.query); +} +``` + +```js +// ✅ Do: values are read on every use, through the functions +class Filter { + #getItems; + #getQuery; + + constructor(getItems, getQuery) { + this.#getItems = getItems; + this.#getQuery = getQuery; + } + + get results() { + return this.#getItems().filter((item) => + item.matches(this.#getQuery()) + ); + } +} + +export default class SearchResults extends Component { + filter = new Filter( + () => this.args.items, + () => this.query + ); +} +``` + +In the first version, `Filter` sees the items and query from the moment the +component was constructed, forever. In the second, every read of +`filter.results` calls the two functions, which read the component's _current_ +tracked state. Consumption flows through the function call, so `results` stays +just as live as a getter defined on the component itself. + +The guideline: pass a plain value when the receiver should see a snapshot; +pass a function when the receiver should keep seeing the current value over +time. + +
+
+
+
Zoey says...
+
+ You rarely need this technique in templates. Component arguments are already lazy: @items={{this.items}} isn't consumed until the child actually reads this.args.items. Deferring with functions is a tool for plain JavaScript, where evaluation is eager. +
+
+ +
+
## Thinking in Derivations @@ -248,7 +378,7 @@ selectPlan = (plan) => { this.total = this.price - this.discount; }; -// ✅ Do: the handler records one fact; formulas do the rest +// ✅ Do: the handler records one fact; getters do the rest selectPlan = (plan) => { this.selectedPlan = plan; }; @@ -270,5 +400,5 @@ In the first version, `total` is only correct if every code path that touches any input remembers to recompute it. In the second, `total` _cannot_ be wrong. Toggling `isAnnual` from a completely different part of the app updates it automatically, through code that was written without any knowledge of that -future feature. That's the payoff of the formula layer, and it's why "derive, +future feature. That's the payoff of derived state, and it's why "derive, don't sync" is the central habit of reactive programming. diff --git a/guides/release/in-depth-topics/reactivity/index.md b/guides/release/in-depth-topics/reactivity/index.md index e780d46fac..5e986012a8 100644 --- a/guides/release/in-depth-topics/reactivity/index.md +++ b/guides/release/in-depth-topics/reactivity/index.md @@ -13,24 +13,17 @@ the [TC39 Signals proposal](https://github.com/tc39/proposal-signals) are built on the same foundations - so learning them will help you reason about UI state in any framework. -## The Spreadsheet Mental Model +## The Three Layers of Reactive State -The oldest and best mental model for reactivity is a spreadsheet. +Every reactive application is built from three layers: -In a spreadsheet, some cells contain plain _values_ that you type in: -`A1 = 5`, `A2 = 10`. Other cells contain _formulas_ that reference those -values: `A3 = A1 + A2`. When you change `A1`, you don't tell `A3` to update - -it just does. A formula can also reference other formulas, building up a whole -graph of computation that stays consistent no matter which value you edit. - -Every reactive system is this spreadsheet, generalized: - -- **Root state** is the cells you type into: the values that change directly, - because a user clicked something, a server responded, or time passed. In - Ember, root state is what you mark with `@tracked`. -- **Derived state** is the formulas: values computed _from_ root state, or - from other derived state. In Ember, derived state is ordinary getters, - functions, and template expressions. +- **Root state** is the values that change directly, because a user clicked + something, a server responded, or time passed. In Ember, root state is what + you mark with `@tracked`. +- **Derived state** is the values computed _from_ root state, or from other + derived state. When you change a piece of root state, you don't tell the + derived values to update - they just do. In Ember, derived state is + ordinary getters, functions, and template expressions. - **Outputs** are where your data meets the outside world: the rendered DOM, the document title, a chart drawn on a canvas. In Ember, the primary output is your templates. The renderer watches everything your templates read, and @@ -53,7 +46,7 @@ export default class Cart extends Component { // Root state: the values that change directly @tracked items = []; - // Derived state: formulas over root state + // Derived state: computed from root state get subtotal() { return this.items.reduce((sum, item) => sum + item.price, 0); } @@ -199,8 +192,8 @@ The rest of this section works through each layer of the model: - [Root State](./root-state/) - what should (and should not) be root state, and how to design it. -- [Derived State](./derived-state/) - formulas: laziness, purity, caching, and - composition. +- [Derived State](./derived-state/) - laziness, purity, caching, composition, + and deferring consumption. - [Reactivity and the Outside World](./outside-world/) - outputs, side effects, async, and the edges of the graph. diff --git a/guides/release/in-depth-topics/reactivity/root-state.md b/guides/release/in-depth-topics/reactivity/root-state.md index 308aa3fd72..16ca56907c 100644 --- a/guides/release/in-depth-topics/reactivity/root-state.md +++ b/guides/release/in-depth-topics/reactivity/root-state.md @@ -3,7 +3,7 @@ _directly_, rather than being computed from something else. Everything else in your application - derived values, rendered DOM - is a consequence of root state. That makes designing your root state the highest-leverage decision you make when managing UI state. Get it right, and the rest of your code becomes -formulas that can't fall out of sync. +derived values that can't fall out of sync. In Ember, you create root state by marking a property as tracked: @@ -239,6 +239,92 @@ you later change how items are stored, nothing outside the class notices. Classes like this need no framework machinery at all; they work in components, services, route models, and plain unit tests alike. +## Classes Are Root State, Too + +Step back and look at what `Cart` is: tracked storage plus the getters +derived from it, bundled behind one reference. From the outside, an _instance_ +of `Cart` is a single reactive value - root state that happens to be +non-primitive. A tracked property can hold a number or a string; it can just +as well hold a `Cart`. + +That gives you two levels of granularity, and the dirtying rule from earlier +applies to both: + +- **Mutate the instance** - call `cart.add(product)` - and only consumers of + the affected internal state invalidate. +- **Replace the instance** - assign a new `Cart` to a tracked property - and + everything that read any part of it invalidates. That's a "reset all" in a + single write: + +```js +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +export default class Checkout extends Component { + // The reference is root state; so is the storage inside the instance + @tracked cart = new Cart(); + + startOver = () => { + this.cart = new Cart(); + }; +} +``` + +Because these are plain classes, their lifetime is ordinary JavaScript +lifetime: an instance lives as long as something references it. Create one in +a component field, and it lives and dies with the component. Create one per +row of a table, and each lives as long as its row is rendered. No framework +registration is needed - garbage collection is the cleanup. + +The exception is a class that starts an external process: a timer, a +subscription, a socket. Garbage collection won't stop those, so tie the +instance's lifetime to its owner with the tools from +[`@ember/destroyable`](https://api.emberjs.com/ember/release/modules/@ember%2Fdestroyable): + +```js {data-filename=app/components/dashboard.gjs} +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { + associateDestroyableChild, + registerDestructor, +} from '@ember/destroyable'; + +class Poller { + @tracked lastReading = null; + + constructor() { + let timer = setInterval(() => this.poll(), 5000); + registerDestructor(this, () => clearInterval(timer)); + } + + async poll() { + let response = await fetch('/api/readings/latest'); + this.lastReading = await response.json(); + } +} + +export default class Dashboard extends Component { + poller = associateDestroyableChild(this, new Poller()); + + +} +``` + +`registerDestructor` gives `Poller` its cleanup; `associateDestroyableChild` +links it to the component, so when the component is destroyed, the poller is +destroyed with it. The +[Reactivity and the Outside World](../outside-world/) guide covers this +pattern - effects tied to a lifetime - in depth. + +One more design rule for state classes: when a class needs to read state that +lives somewhere else (component arguments, another class's tracked fields), +have it accept _functions_ rather than values, so that it reads the current +state on every use instead of a snapshot from construction time. See +[Deferring Consumption](../derived-state/#toc_deferring-consumption) for the +full pattern. + ## Reactive Values Without Classes [RFC #1071](https://github.com/emberjs/rfcs/pull/1071) also overloads From 2003404630c59c85f6fc25e89b7547bfb7298f23 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:00:57 -0400 Subject: [PATCH 4/8] Up --- .../reactivity/derived-state.md | 13 +++-- .../in-depth-topics/reactivity/index.md | 37 ++------------ .../reactivity/outside-world.md | 50 ++++++++----------- .../in-depth-topics/reactivity/root-state.md | 40 ++++----------- 4 files changed, 41 insertions(+), 99 deletions(-) diff --git a/guides/release/in-depth-topics/reactivity/derived-state.md b/guides/release/in-depth-topics/reactivity/derived-state.md index 11f5b60b43..6b95b70bd4 100644 --- a/guides/release/in-depth-topics/reactivity/derived-state.md +++ b/guides/release/in-depth-topics/reactivity/derived-state.md @@ -78,8 +78,8 @@ changes," you are looking for something other than derived state - see A derivation's job is to compute a value from its inputs. It must not _change_ anything - and above all, it must not write to tracked state. This rule is -universal across reactive systems (Solid's documentation gives the same -warning about memos), and Ember enforces it: writing to a tracked value that +universal across reactive systems, and Ember enforces it: writing to a +tracked value that has already been read during the current render throws a development-mode error: @@ -118,9 +118,9 @@ framework. ## Caching By default, a getter recomputes every time it is read. This surprises people -coming from systems whose derivations are memoized by default, like Solid's -`createMemo` and Signalium's `reactive` functions - but it's the right -default, because most derivations are cheap and a cache has its own costs. +coming from systems whose derivations are memoized by default - but it's the +right default, because most derivations are cheap and a cache has its own +costs. Recomputing `this.items.length` is faster than checking whether a cached copy is still valid. @@ -146,8 +146,7 @@ inputs is invalidated; then the next read recomputes. Note what `@cached` does _not_ do: it doesn't compare the new result to the old one. If `transactions` is invalidated but the sorted output happens to -come out identical, consumers downstream are still re-evaluated. Some systems -(Solid's memos, Signalium) add an equality cutoff here; Ember today does not. +come out identical, consumers downstream are still re-evaluated. Beyond raw cost, there are two more good reasons to reach for `@cached`: diff --git a/guides/release/in-depth-topics/reactivity/index.md b/guides/release/in-depth-topics/reactivity/index.md index 5e986012a8..7906988d1f 100644 --- a/guides/release/in-depth-topics/reactivity/index.md +++ b/guides/release/in-depth-topics/reactivity/index.md @@ -7,11 +7,9 @@ You have been using Ember's reactivity system, called _autotracking_, since your first `@tracked` property. The guides in this section go deeper than the API: they cover how to _think_ about reactivity, so that you can design state that stays correct as your application grows. These ideas are not unique to -Ember - systems like [Solid](https://www.solidjs.com/), -[Starbeam](https://starbeamjs.com/), [Signalium](https://signalium.dev/), and -the [TC39 Signals proposal](https://github.com/tc39/proposal-signals) are -built on the same foundations - so learning them will help you reason about UI -state in any framework. +Ember - the broader JavaScript ecosystem calls them _signals_, and most modern +frameworks are built on the same foundations - so learning them will help you +reason about UI state in any framework. ## The Three Layers of Reactive State @@ -159,34 +157,7 @@ don't know the model: offer one; the [Reactivity and the Outside World](./outside-world/) guide explains why, and what to do instead. -## The Same Ideas, Elsewhere - -If you've used other reactive systems - or read about "signals," which is what -the broader JavaScript ecosystem calls these ideas - here is how the -vocabulary maps: - -| Framework / library | Root state | Derived state | Outputs | -| ------------------- | ----------------------- | ------------------------ | ------------------------ | -| Ember | `@tracked`, `tracked()` | getters, `@cached` | templates (the renderer) | -| Svelte | `$state` | `$derived` | templates, `$effect` | -| Vue | `ref`, `reactive` | `computed` | templates, `watch` | -| Angular | `signal` | `computed` | templates, `effect` | -| Solid | `createSignal` | functions, `createMemo` | JSX, `createEffect` | -| Starbeam | cells, `reactive` | formulas, getters | renderer, resources | -| Signalium | `signal` | `reactive` functions | watchers, relays | - -A note on the comparisons: these tools don't all sit at the same level of -abstraction. Starbeam and Signalium are reactivity libraries rather than full -application frameworks, and Solid - though you can build applications with -it - is significantly lower-level than Ember. It's often described as a -framework for building frameworks, which is why it hands you primitives like -`createEffect` directly, where Ember routes the same job through the renderer -and lifecycle-managed constructs. - -The other differences are mostly at the edges: when computation happens -(Solid's effects are eager; Ember and Signalium are lazy), and how outputs are -expressed. The core - consume on read, invalidate on write, derive everything -you can - is the same everywhere. +## Where to Go from Here The rest of this section works through each layer of the model: diff --git a/guides/release/in-depth-topics/reactivity/outside-world.md b/guides/release/in-depth-topics/reactivity/outside-world.md index 98d57808b7..3883b1aa97 100644 --- a/guides/release/in-depth-topics/reactivity/outside-world.md +++ b/guides/release/in-depth-topics/reactivity/outside-world.md @@ -17,44 +17,39 @@ Everything in between stays pure. ## Rendering Is the Effect In some reactive systems, you wire outputs yourself with an _effect_ -primitive. Solid's `createEffect`, for example, re-runs a function whenever -the reactive values it read change. If you ask "where is Ember's -`createEffect`?", the first answer is: **you've been using it all along - it's -the renderer.** +primitive: a function that re-runs whenever the reactive values it read +change. If you ask "where is Ember's effect primitive?", the first answer is: +**you've been using it all along - it's the renderer.** A template is a declaration of effects: every `{{expression}}` and every attribute binding is a tiny "when this value changes, update that DOM" rule. -The renderer plays the same role Signalium assigns to its _watchers_: it is -the exit point of the graph, the thing that actively pulls on your derivations -and pushes the results into the world. You write the pure part; the framework -owns the part that touches the world - batching, scheduling before paint, and -updating only the DOM whose inputs actually changed. +The renderer is the exit point of the graph, the thing that actively pulls on +your derivations and pushes the results into the world. You write the pure +part; the framework owns the part that touches the world - batching, +scheduling before paint, and updating only the DOM whose inputs actually +changed. -## Why There Is No `createEffect` +## Why There Is No Effect Primitive The second answer is that the omission is deliberate, and the reasons are -instructive. You can find most of them stated as _warnings_ in the -documentation of frameworks that have effects: +instructive: - **Effects are eager.** An effect must re-run on every change to its inputs, whether or not anyone needs the result, breaking the lazy, pull-based - economics that make the rest of the system cheap. (Signalium, which is lazy - like Ember, allows watchers but tells you never to create them inside - reactive code.) + economics that make the rest of the system cheap. - **Effect ordering is undefined.** When one change triggers several effects, - the execution order is unspecified - Solid's documentation says plainly that - it "should not be relied upon." Correctness that depends on effect order is - a latent bug. + the execution order is unspecified. Correctness that depends on effect + order is a latent bug. - **Effects that write state are a trap.** The most common effect mistake is using one to _sync_ state: "when X changes, update Y." Now Y is stale until the effect runs, can disagree with X, and if the effect's write triggers - another effect, you have a cascade or an infinite loop. Solid's own guides - answer this with "use `createMemo` instead" - that is: _derive, don't sync_. - In Ember, [the getter was the answer all along](../derived-state/), and the + another effect, you have a cascade or an infinite loop. The answer is to + _derive, don't sync_: + in Ember, [the getter was the answer all along](../derived-state/), and the backtracking assertion makes write-during-derivation a loud error rather than a quiet bug. - **Almost every "effect" is something more specific.** Look closely at real - `createEffect` calls and you find: derived values (should be getters), DOM + effect code and you find: derived values (should be getters), DOM manipulation (should be scoped to an element), and lifecycle-bound processes like subscriptions (should be tied to an owner's lifetime, with cleanup). Ember provides each of those as a dedicated, managed construct instead of @@ -125,10 +120,8 @@ remaining," "is the store open," a formatted timestamp - and every one of them updates each second, while the actual side effect (one interval, one cleanup) stays in one place. -This setup-plus-cleanup-plus-reactive-state package is what Starbeam calls a -_resource_, and its docs model the same example almost identically: a `Clock` -resource whose `setInterval` is started in setup and cleared in cleanup. In -the Ember ecosystem, the +This setup-plus-cleanup-plus-reactive-state package is commonly called a +_resource_. In the Ember ecosystem, the [ember-resources](https://github.com/NullVoxPopuli/ember-resources) library offers resources as values you can use right in components and templates; a built-in equivalent is an active area of design. A service with a destructor, @@ -224,10 +217,7 @@ export default class Profile extends Component { Once async state is _data_, it stops being a special case: "show a spinner while pending" is just another derivation, the same `{{#if}}` as anything else. This "reactive promise" shape - status, value, and error as reactive -fields - is where the whole ecosystem has converged. Signalium builds it in as -`ReactivePromise` (with `isPending`, `isResolved`, `value`, and friends), -Solid's resources and Starbeam's resources wrap it in lifetime management, and -in Ember it's available today via libraries like +fields - is available ready-made from libraries like [ember-resources](https://github.com/NullVoxPopuli/ember-resources) and [WarpDrive](https://docs.warp-drive.io/)'s request state - or in a dozen lines of your own, as above. diff --git a/guides/release/in-depth-topics/reactivity/root-state.md b/guides/release/in-depth-topics/reactivity/root-state.md index 16ca56907c..befdc066fe 100644 --- a/guides/release/in-depth-topics/reactivity/root-state.md +++ b/guides/release/in-depth-topics/reactivity/root-state.md @@ -57,9 +57,7 @@ optimization is usually imaginary. When a derivation really is expensive, you can [cache it](../derived-state/#toc_caching) instead of promoting it to root state. -A useful instinct from the -[Solid](https://docs.solidjs.com/concepts/intro-to-reactivity) and -[Starbeam](https://starbeamjs.com/) communities: a well-factored reactive +A useful instinct: a well-factored reactive application has surprisingly _little_ root state. A search page might have exactly two root values - the query string and the raw results - while everything else on screen (filtered lists, counts, empty-state flags, disabled @@ -67,7 +65,7 @@ buttons) is derived. ## Writes, Equality, and Dirtying -When does a write actually invalidate things? In Ember today, the answer is +When does a write actually invalidate things? By default, the answer is simple: _every_ assignment to a tracked property dirties it, even if you assign the value it already had. @@ -77,14 +75,11 @@ this.count = this.count; // still invalidates everything that consumed `count` Consumers re-evaluate, and the renderer re-checks the DOM it produced. The DOM itself won't change if the final values are equal, but the recomputation -happens. Other systems make the opposite choice: Solid's signals compare the -new value to the old one - `===` by default - and do nothing if they're equal, -cutting invalidation off at the source. Signalium's `signal()` behaves -similarly. +happens. -[RFC #1071](https://github.com/emberjs/rfcs/pull/1071) brings that dial to -Ember. The decorator accepts an options form, `@tracked({ equals })`, that -skips invalidation entirely when the old and new values are equal: +When a write that changes nothing shouldn't dirty anything, the decorator +accepts an options form, `@tracked({ equals })`, that skips invalidation +entirely when the old and new values are equal: ```js import { tracked } from '@glimmer/tracking'; @@ -123,18 +118,6 @@ conclude that nothing changed. An equality check cuts that work off at the root. The flip side: the check itself runs on every write, so for values that really do change on most writes, the default is the cheaper choice. -
-
-
-
Zoey says...
-
- The @tracked({ equals }) form and the tracked() function described later on this page come from RFC #1071, which is implemented but has not yet shipped in a stable Ember release. Until it ships, you can get equality-checking behavior by guarding the write yourself: if (next !== this.count) this.count = next; -
-
- -
-
- Because dirtying is per-property, the _granularity_ of your root state determines the granularity of updates. Three tracked properties invalidate independently; one tracked object replaced wholesale invalidates everything @@ -199,8 +182,8 @@ places. Root state is an implementation detail. The code that _uses_ your state shouldn't know (or care) which parts are stored and which are computed. A -pattern used heavily in Starbeam's documentation - and just as good in Ember - -is to keep reactive storage private and expose a domain-shaped public API: +good pattern is to keep reactive storage private and expose a domain-shaped +public API: ```js import { trackedMap } from '@ember/reactive/collections'; @@ -327,10 +310,9 @@ full pattern. ## Reactive Values Without Classes -[RFC #1071](https://github.com/emberjs/rfcs/pull/1071) also overloads -`tracked` to work as a plain function. Called with a value instead of applied -as a decorator, it returns a standalone reactive value - root state that isn't -attached to any class: +`tracked` also works as a plain function. Called with a value instead of +applied as a decorator, it returns a standalone reactive value - root state +that isn't attached to any class: ```js import { tracked } from '@glimmer/tracking'; From 142e41b5963fc8fb861b462dbe7449bc08374908 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:13:26 -0400 Subject: [PATCH 5/8] Up --- .../in-depth-topics/autotracking-in-depth.md | 6 +- .../reactivity/derived-state.md | 11 +- .../in-depth-topics/reactivity/index.md | 20 +-- .../reactivity/outside-world.md | 7 +- .../in-depth-topics/reactivity/root-state.md | 117 ++++++++++-------- 5 files changed, 97 insertions(+), 64 deletions(-) diff --git a/guides/release/in-depth-topics/autotracking-in-depth.md b/guides/release/in-depth-topics/autotracking-in-depth.md index c64882e097..2f68ad1e96 100644 --- a/guides/release/in-depth-topics/autotracking-in-depth.md +++ b/guides/release/in-depth-topics/autotracking-in-depth.md @@ -348,9 +348,9 @@ All property reading and writing on this object is automatically tracked. `obj.c.somethingDeeper = 5` would not be tracked unless you've also made sure that the contents of `obj.c` is itself another `trackedObject`. -For guidance on when to reach for a tracked collection versus replacing a -value wholesale, see -[Root State](../reactivity/root-state/#toc_mutable-data-replace-or-track-the-collection). +For guidance on why tracked collections are preferred over replacing values +wholesale, see +[Root State](../reactivity/root-state/#toc_mutable-data-track-the-collection). #### Arrays diff --git a/guides/release/in-depth-topics/reactivity/derived-state.md b/guides/release/in-depth-topics/reactivity/derived-state.md index 6b95b70bd4..d9adad1b57 100644 --- a/guides/release/in-depth-topics/reactivity/derived-state.md +++ b/guides/release/in-depth-topics/reactivity/derived-state.md @@ -135,7 +135,7 @@ class Report { @cached get sortedByAmount() { - return [...this.transactions].sort((a, b) => b.amount - a.amount); + return this.transactions.toSorted((a, b) => b.amount - a.amount); } } ``` @@ -243,7 +243,10 @@ is always safe. Reading a tracked value consumes it _right now_. Both of Ember's derivation tools - getters and functions - work by _deferring_ that read: nothing runs -when they're defined, only when someone asks for the result. +when they're defined, only when someone asks for the result. This is the +[laziness from earlier](#toc_derivations-are-lazy) seen from the consumer's +side: deferring the read is what places consumption in the right tracking +context. You've been deferring with getters all along; it's the default style when working with classes. A getter's body runs when the property is read, so its @@ -300,6 +303,8 @@ class Filter { } export default class SearchResults extends Component { + @tracked query = ''; + filter = new Filter(this.args.items, this.query); } ``` @@ -323,6 +328,8 @@ class Filter { } export default class SearchResults extends Component { + @tracked query = ''; + filter = new Filter( () => this.args.items, () => this.query diff --git a/guides/release/in-depth-topics/reactivity/index.md b/guides/release/in-depth-topics/reactivity/index.md index 7906988d1f..e06484e93e 100644 --- a/guides/release/in-depth-topics/reactivity/index.md +++ b/guides/release/in-depth-topics/reactivity/index.md @@ -17,7 +17,8 @@ Every reactive application is built from three layers: - **Root state** is the values that change directly, because a user clicked something, a server responded, or time passed. In Ember, root state is what - you mark with `@tracked`. + you mark with `@tracked` or store in a tracked collection such as + `trackedArray`. - **Derived state** is the values computed _from_ root state, or from other derived state. When you change a piece of root state, you don't tell the derived values to update - they just do. In Ember, derived state is @@ -36,13 +37,13 @@ Here is what all three layers look like in a single component: ```gjs {data-filename=app/components/cart.gjs} import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; +import { trackedArray } from '@ember/reactive/collections'; import { on } from '@ember/modifier'; import { fn } from '@ember/helper'; export default class Cart extends Component { // Root state: the values that change directly - @tracked items = []; + items = trackedArray([]); // Derived state: computed from root state get subtotal() { @@ -59,7 +60,7 @@ export default class Cart extends Component { addItem = (item) => { // Events write to root state; everything else updates on its own - this.items = [...this.items, item]; + this.items.push(item); }; // Output: the rendered page @@ -72,7 +73,8 @@ export default class Cart extends Component { } ``` -Notice the proportions in this component: one tracked property, three getters. +Notice the proportions in this component: one piece of root state, three +getters. This is typical of well-designed reactive code, and it is the most important habit this section hopes to teach: **most of your state should be derived, and only the irreducible minimum should be root state.** The @@ -92,10 +94,10 @@ operations: That's the whole trick! When Ember renders `{{this.total}}` in the component above, it evaluates `total`, which reads `subtotal` and `tax`, which read -`items` - and because `items` is tracked, that read is _consumed_. Later, when -`addItem` assigns to `this.items`, the write _invalidates_ the rendered -output, and Ember schedules a rerender of exactly the parts of the DOM that -consumed it. +`items` - and because `items` is a tracked collection, those reads are +_consumed_. Later, when `addItem` pushes into `this.items`, the write +_invalidates_ the rendered output, and Ember schedules a rerender of exactly +the parts of the DOM that consumed it. Two properties of this design are worth internalizing. diff --git a/guides/release/in-depth-topics/reactivity/outside-world.md b/guides/release/in-depth-topics/reactivity/outside-world.md index 3883b1aa97..b3074be4a5 100644 --- a/guides/release/in-depth-topics/reactivity/outside-world.md +++ b/guides/release/in-depth-topics/reactivity/outside-world.md @@ -14,6 +14,9 @@ The shape to keep in mind: Everything in between stays pure. +We'll start with outputs - that's where Ember differs most from other +reactive systems - and work back around to inputs. + ## Rendering Is the Effect In some reactive systems, you wire outputs yourself with an _effect_ @@ -224,7 +227,9 @@ of your own, as above. Note one limitation of the example as written: the request is created in a field initializer, so it captures `userId` once and won't re-fetch if the -argument changes. Re-running an effect when its reactive inputs change is +argument changes - the construction-time snapshot trap described in +[Deferring Consumption](../derived-state/#toc_deferring-consumption). +Re-running an effect when its reactive inputs change is exactly the job of the managed constructs from earlier - a modifier (if there's a sensible element) or a resource. That's the general rule of this guide closing the loop: **when an effect needs to respond to the graph, give diff --git a/guides/release/in-depth-topics/reactivity/root-state.md b/guides/release/in-depth-topics/reactivity/root-state.md index befdc066fe..928d107f3c 100644 --- a/guides/release/in-depth-topics/reactivity/root-state.md +++ b/guides/release/in-depth-topics/reactivity/root-state.md @@ -63,6 +63,40 @@ exactly two root values - the query string and the raw results - while everything else on screen (filtered lists, counts, empty-state flags, disabled buttons) is derived. +## Root State Is Not a Cache for Someone Else's Truth + +A special case of "could I compute this instead?" arises with _data that +arrives from elsewhere_: arguments passed to your component, records from your +data layer, the current route. The reactive system already tracks these. +Copying them into your own tracked properties creates the synchronization +problem all over again: + +```js +// 🛑 Don't: copying an argument into root state +class UserCard extends Component { + @tracked displayName = this.args.user.name; +} +``` + +This captures `name` once and goes stale when the argument changes. Deriving +stays current automatically: + +```js +// ✅ Do: derive from the argument +class UserCard extends Component { + get displayName() { + return this.args.user.name ?? 'Anonymous'; + } +} +``` + +If you genuinely need "the argument, until the user edits it locally" (a form +draft, for example), that's _new_ root state whose initial value happens to +come from elsewhere. Create it explicitly in response to a user action, not by +mirroring the argument on every change. The +[Patterns for Components](../../patterns-for-components/) guide shows several +shapes of this. + ## Writes, Equality, and Dirtying When does a write actually invalidate things? By default, the answer is @@ -121,9 +155,11 @@ really do change on most writes, the default is the cheaper choice. Because dirtying is per-property, the _granularity_ of your root state determines the granularity of updates. Three tracked properties invalidate independently; one tracked object replaced wholesale invalidates everything -that read any part of it. Neither is wrong - but it's a dial you control. +that read any part of it. Reactive systems thrive on fine-grained changes: +prefer shapes that let you write exactly the piece that changed, and keep the +_identity_ of everything else stable. -## Mutable Data: Replace or Track the Collection +## Mutable Data: Track the Collection `@tracked` tracks _assignments to the property_, not mutations inside the value. Pushing into a plain array or setting a key on a plain object is @@ -139,16 +175,7 @@ class ShoppingList { } ``` -You have two good options here. The first is to treat values as immutable and -_replace_ them, which keeps all change flowing through the one tracked write: - -```js -addItem = (item) => { - this.items = [...this.items, item]; -}; -``` - -The second is to use a tracked collection from +The right tool here is a tracked collection from [`@ember/reactive/collections`](https://api.emberjs.com/ember/release/modules/@ember%2Freactive%2Fcollections), which tracks reads and writes of its _contents_ at fine granularity: @@ -174,9 +201,25 @@ objects stored _inside_ it are ordinary objects unless you wrap them too. See for the full tour of `trackedObject`, `trackedArray`, `trackedMap`, and `trackedSet`. -Prefer replacement for small values and value-like data. Prefer tracked -collections when a collection is long-lived, large, or mutated from many -places. +You may also see code that _replaces_ the value instead, assigning a +brand-new array to the tracked property: + +```js +addItem = (item) => { + this.items = this.items.concat(item); // works, but has caveats +}; +``` + +The assignment is a tracked write, so this updates - but it is the coarsest +change there is. Every consumer of `items` invalidates, even ones that only +cared about one entry, and the array's identity changes on every update, +which defeats downstream `===` checks and caches (see +[stable identity](../derived-state/#toc_caching)). "One item changed" becomes +"everything changed." Retain identity wherever possible: keep one long-lived +tracked collection and mutate it, so the system invalidates only what +actually changed. Replacement is best reserved for genuinely value-like data +- a string, a date, a small tuple - where the new value simply _is_ a +different value. ## Keep Root State Private, Expose Meaning @@ -194,7 +237,7 @@ export class Cart { // Public API: domain-shaped, read-only, derived get items() { - return [...this.#items.values()]; + return Array.from(this.#items.values()); } get isEmpty() { @@ -253,6 +296,10 @@ export default class Checkout extends Component { } ``` +Day to day, prefer mutation: it keeps the instance's identity stable and +invalidation precise. Reach for replacement only when you genuinely mean +"this is a whole new thing," as `startOver` does above. + Because these are plain classes, their lifetime is ordinary JavaScript lifetime: an instance lives as long as something references it. Create one in a component field, and it lives and dies with the component. Create one per @@ -409,36 +456,8 @@ be module-scoped almost always wants to be a service, which is created and destroyed per application instance (and per test). The exception is demos and scratch code, where module state's brevity is the point. -## Root State Is Not a Cache for Someone Else's Truth - -A special case of "could I compute this instead?" arises with _data that -arrives from elsewhere_: arguments passed to your component, records from your -data layer, the current route. The reactive system already tracks these. -Copying them into your own tracked properties creates the synchronization -problem all over again: - -```js -// 🛑 Don't: copying an argument into root state -class UserCard extends Component { - @tracked displayName = this.args.user.name; -} -``` - -This captures `name` once and goes stale when the argument changes. Deriving -stays current automatically: - -```js -// ✅ Do: derive from the argument -class UserCard extends Component { - get displayName() { - return this.args.user.name ?? 'Anonymous'; - } -} -``` - -If you genuinely need "the argument, until the user edits it locally" (a form -draft, for example), that's _new_ root state whose initial value happens to -come from elsewhere. Create it explicitly in response to a user action, not by -mirroring the argument on every change. The -[Patterns for Components](../../patterns-for-components/) guide shows several -shapes of this. +To sum up: store only what you can't compute, decide how writes should dirty +it, pick the shape that fits (a tracked property, a collection, a class, a +standalone value), and give it an owner whose lifetime matches the state's. +Everything else in your application should be +[derived state](../derived-state/) - which is the subject of the next guide. From 3befb9d1832d8e71bd4c09a809a5fa460988c1d1 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:11:22 -0400 Subject: [PATCH 6/8] Up --- .../reactivity/derived-state.md | 122 ++++++------ .../in-depth-topics/reactivity/index.md | 97 +++++----- .../reactivity/outside-world.md | 116 ++++++------ .../in-depth-topics/reactivity/root-state.md | 174 +++++++++--------- 4 files changed, 248 insertions(+), 261 deletions(-) diff --git a/guides/release/in-depth-topics/reactivity/derived-state.md b/guides/release/in-depth-topics/reactivity/derived-state.md index d9adad1b57..564974bc41 100644 --- a/guides/release/in-depth-topics/reactivity/derived-state.md +++ b/guides/release/in-depth-topics/reactivity/derived-state.md @@ -1,9 +1,8 @@ -Derived state is everything computed _from_ [root state](../root-state/). In -a healthy Ember application, this is most of your state - and in Ember, it -requires no special API at all. An -ordinary getter, an ordinary function, an ordinary template expression: if it -reads tracked state, it is derived state, and it stays up to date -automatically. +Derived state is everything computed from [root state](../root-state/). In a +healthy Ember application, this is most of your state - and in Ember, it +requires no special API at all. An ordinary getter, an ordinary function, an +ordinary template expression: if it reads tracked state, it is derived state, +and it stays up to date automatically. ```js import { tracked } from '@glimmer/tracking'; @@ -35,8 +34,8 @@ itself, by watching what each computation reads while it runs. ## Derivations Are Lazy -The most important thing to understand about derived state in Ember: -**changing root state does not run your getters.** A write to `@tracked` state +The most important thing to understand about derived state in Ember is that +changing root state does not run your getters. A write to `@tracked` state only marks the things that consumed it as out of date. The getter runs again when - and only when - something actually reads it. If nothing reads it, it never runs. @@ -61,27 +60,26 @@ search.query = 'Hello, world!'; search.normalizedQuery; // logs "computing!" - exactly once ``` -This is _pull-based_ reactivity, described in +This is pull-based reactivity, described in [Thinking in Reactivity](../), and it's why you can be generous with derived state. A getter that nothing currently displays costs nothing, no matter how often its inputs change. Ten getters reading the same tracked property add no overhead to writes. Work happens at read time, driven by what the page actually needs. -The corollary: **never rely on a getter running for its timing.** A derivation -may run once, many times, or never; it may run later than you expect or more -often than you expect. If you find yourself wanting "run this code _when_ X -changes," you are looking for something other than derived state - see -[Reactivity and the Outside World](../outside-world/). +One consequence is that you should never rely on when, or whether, a getter +runs. A derivation may run once, many times, or never; it may run later than +you expect or more often than you expect. If you find yourself wanting "run +this code when X changes," you are looking for something other than derived +state - see [Reactivity and the Outside World](../outside-world/). ## Derivations Must Be Pure -A derivation's job is to compute a value from its inputs. It must not _change_ +A derivation's job is to compute a value from its inputs. It must not change anything - and above all, it must not write to tracked state. This rule is universal across reactive systems, and Ember enforces it: writing to a -tracked value that -has already been read during the current render throws a development-mode -error: +tracked value that has already been read during the current render throws a +development-mode error: ```text Error: You attempted to update `count`, but it had already been used @@ -89,9 +87,9 @@ previously in the same computation. ``` This is sometimes called the _backtracking assertion_: render evaluates your -derivations top to bottom, and a write partway through would invalidate output -that was already produced. The fix is never to find a sneakier place for the -write; it's to restructure so the write isn't needed: +derivations top to bottom, and a write partway through would invalidate +output that was already produced. When you hit this error, restructure the +code so the write isn't needed: ```js // 🛑 Don't: a "derivation" that pushes its result somewhere else @@ -111,8 +109,8 @@ get resultCount() { } ``` -Purity is also what makes derived state effortless to test: `new Search()`, -set some properties, assert on some getters. No rendering, no waiting, no +Purity is also what makes derived state easy to test: `new Search()`, set +some properties, assert on some getters. No rendering, no waiting, no framework. ## Caching @@ -120,11 +118,10 @@ framework. By default, a getter recomputes every time it is read. This surprises people coming from systems whose derivations are memoized by default - but it's the right default, because most derivations are cheap and a cache has its own -costs. -Recomputing `this.items.length` is faster than checking whether a cached copy -is still valid. +costs. Recomputing `this.items.length` is faster than checking whether a +cached copy is still valid. -When a derivation _is_ genuinely expensive - sorting thousands of rows, +When a derivation is genuinely expensive - sorting thousands of rows, building a chart's dataset - mark it with `@cached`: ```js @@ -144,18 +141,18 @@ A `@cached` getter remembers its result along with everything it consumed while computing it. Reads return the cached value until one of those consumed inputs is invalidated; then the next read recomputes. -Note what `@cached` does _not_ do: it doesn't compare the new result to the -old one. If `transactions` is invalidated but the sorted output happens to -come out identical, consumers downstream are still re-evaluated. +Note what `@cached` does not do: it doesn't compare the new result to the old +one. If `transactions` is invalidated but the sorted output happens to come +out identical, consumers downstream are still re-evaluated. Beyond raw cost, there are two more good reasons to reach for `@cached`: -- **Stable identity.** An uncached getter that returns a fresh array or object - on every read can defeat downstream `===` checks and cause child components - to see "new" values that are deep-equal to the old ones. Caching makes the - derivation return the _same_ object until its inputs actually change. -- **Once-per-change semantics.** If a derivation must observably run at most - once per change (because it allocates, logs, or is just very hot), `@cached` +- Stable identity. An uncached getter that returns a fresh array or object on + every read can defeat downstream `===` checks and cause child components to + see "new" values that are deep-equal to the old ones. Caching makes the + derivation return the same object until its inputs actually change. +- Once-per-change semantics. If a derivation must observably run at most once + per change (because it allocates, logs, or is just very hot), `@cached` guarantees that. See [Autotracking In-Depth](../../autotracking-in-depth/#toc_caching-of-tracked-properties) @@ -181,11 +178,11 @@ get headline() { } ``` -Each step is independently readable, testable, and reusable - and invalidation +Each step is independently readable, testable, and reusable, and invalidation stays precise, because each layer only consumes what it actually reads. For derived state that several components need, the same composition rule -applies one level up: put the root state _and_ its derivations together in a +applies one level up: put the root state and its derivations together in a class (as in the `Cart` example in [Root State](../root-state/#toc_keep-root-state-private-expose-meaning)) or a [service](../../../services/), and let components consume the finished @@ -193,9 +190,9 @@ getters. ## Derivations Outside of Classes -Derivations don't have to live on classes. A plain function that reads tracked -state is a derivation too, and in template tag files you can use one directly -as a helper: +Derivations don't have to live on classes. A plain function that reads +tracked state is a derivation too, and in template tag files you can use one +directly as a helper: ```gjs {data-filename=app/components/roster.gjs} import Component from '@glimmer/component'; @@ -217,10 +214,10 @@ export default class Roster extends Component { } ``` -`initials` doesn't read tracked state itself, but it participates in the graph -all the same: it's re-evaluated for a person whenever the `name` passed to it -is invalidated. Pure functions like this - parameterized derivations - are the -most reusable form of derived state. See +`initials` doesn't read tracked state itself, but it participates in the +graph all the same: it's re-evaluated for a person whenever the `name` passed +to it is invalidated. Pure functions like this - parameterized derivations - +are the most reusable form of derived state. See [Helper Functions](../../../components/helper-functions/) for more. The same idea scales up to module scope. A function that takes reactive data @@ -234,14 +231,14 @@ export function subtotal(items) { Wherever this runs during a reactive computation - a template, a getter, another function - reading `items` entangles the caller with that data, and -the result stays live. Note the contrast with module-scoped _state_, which +the result stays live. Note the contrast with module-scoped state, which [should generally be avoided](../root-state/#toc_where-root-state-lives): a derivation function holds no state of its own, so sharing it at module scope is always safe. ## Deferring Consumption -Reading a tracked value consumes it _right now_. Both of Ember's derivation +Reading a tracked value consumes it immediately. Both of Ember's derivation tools - getters and functions - work by _deferring_ that read: nothing runs when they're defined, only when someone asks for the result. This is the [laziness from earlier](#toc_derivations-are-lazy) seen from the consumer's @@ -280,7 +277,7 @@ let getName = () => this.person.name; // reads nothing - yet ``` Arrow functions capture `this` and their surrounding scope, which makes them -_portable_ derivations: you can hand one to another object, and every call +portable derivations: you can hand one to another object, and every call re-reads the current value from wherever the state actually lives. This matters most in constructors and field initializers, because they run @@ -339,13 +336,13 @@ export default class SearchResults extends Component { In the first version, `Filter` sees the items and query from the moment the component was constructed, forever. In the second, every read of -`filter.results` calls the two functions, which read the component's _current_ -tracked state. Consumption flows through the function call, so `results` stays -just as live as a getter defined on the component itself. +`filter.results` calls the two functions, which read the component's current +tracked state. Consumption flows through the function call, so `results` +stays just as live as a getter defined on the component itself. -The guideline: pass a plain value when the receiver should see a snapshot; -pass a function when the receiver should keep seeing the current value over -time. +As a guideline, pass a plain value when the receiver should see a snapshot, +and pass a function when the receiver should keep seeing the current value +over time.
@@ -363,17 +360,16 @@ time. When a new piece of UI state shows up, try these options in order: -1. **Can it be an expression in the template?** +1. Can it be an expression in the template? `{{if @isAdmin "superuser"}}` needs no JavaScript at all. -2. **Can it be a getter or pure function?** This covers nearly everything - else. -3. **Is it genuinely new information that arrives from outside?** Only then is - it [root state](../root-state/). +2. Can it be a getter or pure function? This covers nearly everything else. +3. Is it genuinely new information that arrives from outside? Only then is it + [root state](../root-state/). -A symptom worth watching for: an event handler that updates several tracked +A symptom to watch for: an event handler that updates several tracked properties "to keep them consistent" is almost always storing derivations. -Move the consistency into getters, and let the handler write the one fact that -actually changed: +Move the consistency into getters, and let the handler write the one fact +that actually changed: ```js // 🛑 Don't: the handler maintains derived state by hand @@ -403,7 +399,7 @@ get total() { ``` In the first version, `total` is only correct if every code path that touches -any input remembers to recompute it. In the second, `total` _cannot_ be wrong. +any input remembers to recompute it. In the second, `total` cannot be wrong. Toggling `isAnnual` from a completely different part of the app updates it automatically, through code that was written without any knowledge of that future feature. That's the payoff of derived state, and it's why "derive, diff --git a/guides/release/in-depth-topics/reactivity/index.md b/guides/release/in-depth-topics/reactivity/index.md index e06484e93e..0cefee2916 100644 --- a/guides/release/in-depth-topics/reactivity/index.md +++ b/guides/release/in-depth-topics/reactivity/index.md @@ -5,7 +5,7 @@ state, and the framework figures out when and what to update. You have been using Ember's reactivity system, called _autotracking_, since your first `@tracked` property. The guides in this section go deeper than the -API: they cover how to _think_ about reactivity, so that you can design state +API: they cover how to think about reactivity, so that you can design state that stays correct as your application grows. These ideas are not unique to Ember - the broader JavaScript ecosystem calls them _signals_, and most modern frameworks are built on the same foundations - so learning them will help you @@ -15,15 +15,15 @@ reason about UI state in any framework. Every reactive application is built from three layers: -- **Root state** is the values that change directly, because a user clicked +- _Root state_ is the values that change directly, because a user clicked something, a server responded, or time passed. In Ember, root state is what you mark with `@tracked` or store in a tracked collection such as `trackedArray`. -- **Derived state** is the values computed _from_ root state, or from other +- _Derived state_ is the values computed from root state, or from other derived state. When you change a piece of root state, you don't tell the derived values to update - they just do. In Ember, derived state is ordinary getters, functions, and template expressions. -- **Outputs** are where your data meets the outside world: the rendered DOM, +- _Outputs_ are where your data meets the outside world: the rendered DOM, the document title, a chart drawn on a canvas. In Ember, the primary output is your templates. The renderer watches everything your templates read, and updates the DOM when any of it changes. @@ -73,37 +73,34 @@ export default class Cart extends Component { } ``` -Notice the proportions in this component: one piece of root state, three -getters. -This is typical of well-designed reactive code, and it is the most important -habit this section hopes to teach: **most of your state should be derived, and -only the irreducible minimum should be root state.** The +Notice that this component has one piece of root state and three getters. +This is typical of well-designed reactive code, and it points at the most +important habit these guides hope to teach: most of your state should be +derived, and only the irreducible minimum should be root state. The [Root State](./root-state/) and [Derived State](./derived-state/) guides develop this idea in detail. ## The Two Fundamental Operations Underneath every reactive system - autotracking included - are just two -operations: - -- **Consume**: when a value is _read_ while something reactive is being - computed (a template rendering, a cached getter evaluating), the system - records that the computation used that value. -- **Invalidate** (or _dirty_): when a value is _written_, the system marks - every computation that consumed it as out of date. +operations. When a value is read while something reactive is being computed +(a template rendering, a cached getter evaluating), the system records that +the computation used that value. This is called _consuming_. When a value is +written, the system marks every computation that consumed it as out of date. +This is called _invalidating_, or _dirtying_. That's the whole trick! When Ember renders `{{this.total}}` in the component above, it evaluates `total`, which reads `subtotal` and `tax`, which read `items` - and because `items` is a tracked collection, those reads are -_consumed_. Later, when `addItem` pushes into `this.items`, the write -_invalidates_ the rendered output, and Ember schedules a rerender of exactly +consumed. Later, when `addItem` pushes into `this.items`, the write +invalidates the rendered output, and Ember schedules a rerender of exactly the parts of the DOM that consumed it. -Two properties of this design are worth internalizing. +Two properties of this design come up again and again. -First, **dependencies are discovered at runtime, every time.** You never -declare what a getter depends on; the system records what it _actually reads_ -during each evaluation. This means even conditional dependencies just work: +First, dependencies are discovered at runtime, every time. You never declare +what a getter depends on; the system records what it actually reads during +each evaluation. This means even conditional dependencies just work: ```js get displayName() { @@ -116,45 +113,43 @@ anything, because `displayName` never read it. If `useNickname` becomes `true`, the next evaluation reads `nickname`, and from then on changes to it propagate. The dependency graph rewires itself on every run. -Second, **tracking is synchronous.** The system can only observe reads that -happen _while_ a reactive computation is running. If you read tracked state in -a callback that runs later - after an `await`, or inside a `setTimeout` - that -read happens outside any tracking context, and nothing is consumed. This is -rarely a problem in practice, since templates, getters, and helpers are all -synchronous, but it explains a whole class of "why didn't this update?" bugs. -The [Reactivity and the Outside World](./outside-world/) guide covers this -boundary in detail. +Second, tracking is synchronous. The system can only observe reads that +happen while a reactive computation is running. If you read tracked state in +a callback that runs later - after an `await`, or inside a `setTimeout` - +that read happens outside any tracking context, and nothing is consumed. This +is rarely a problem in practice, since templates, getters, and helpers are +all synchronous, but it explains a whole class of "why didn't this update?" +bugs. The [Reactivity and the Outside World](./outside-world/) guide covers +this boundary in detail. ## Pull, Not Push There are two ways a reactive system can respond to a write: -- A **push**-based system eagerly re-runs every affected computation the - moment a value changes. -- A **pull**-based (or _lazy_) system merely marks affected computations as - out of date, and recomputes them only when someone actually needs their - result. +- A _push_-based system eagerly re-runs every affected computation the moment + a value changes. +- A _pull_-based (or lazy) system merely marks affected computations as out + of date, and recomputes them only when someone actually needs their result. -Autotracking is pull-based. When you write to a tracked property, _no user -code runs_. Your getters are not re-evaluated; nothing is recomputed. The -write just lets the renderer know that something it consumed is out of date. -Later - asynchronously, but before the browser paints - the renderer +Autotracking is pull-based. When you write to a tracked property, no user +code runs at all. Your getters are not re-evaluated; nothing is recomputed. +The write just lets the renderer know that something it consumed is out of +date. Later - asynchronously, but before the browser paints - the renderer re-evaluates the expressions in your templates and updates the DOM. -This has practical consequences that are easy to feel but hard to place if you -don't know the model: +This has practical consequences that are easy to feel but hard to place if +you don't know the model: -- **Writes are cheap, and they coalesce.** Setting ten tracked properties in - one event handler causes one rerender, not ten. You don't need to batch +- Writes are cheap, and they coalesce. Setting ten tracked properties in one + event handler causes one rerender, not ten, so you don't need to batch updates yourself. -- **Unused state is free.** A derived value that nothing currently reads is - never computed, no matter how often its inputs change. Work scales with - what's on the page, not with what's in your data. -- **Reading state never observes a half-applied update.** Because derivations - run on demand rather than in a notification cascade, there is no window - where `tax` has updated but `subtotal` hasn't. Your data is always - internally consistent. -- **There is no "re-run this code when X changes" primitive.** In a push-based +- Unused state is free. A derived value that nothing currently reads is never + computed, no matter how often its inputs change. Work scales with what's on + the page, not with what's in your data. +- Reading state never observes a half-applied update. Because derivations run + on demand rather than in a notification cascade, there is no window where + `tax` has updated but `subtotal` hasn't. +- There is no "re-run this code when X changes" primitive. In a push-based system you might reach for an _effect_ for that. Ember deliberately doesn't offer one; the [Reactivity and the Outside World](./outside-world/) guide explains why, and what to do instead. diff --git a/guides/release/in-depth-topics/reactivity/outside-world.md b/guides/release/in-depth-topics/reactivity/outside-world.md index b3074be4a5..577ced0458 100644 --- a/guides/release/in-depth-topics/reactivity/outside-world.md +++ b/guides/release/in-depth-topics/reactivity/outside-world.md @@ -1,16 +1,17 @@ The reactive graph - [root state](../root-state/) at the bottom, -[derived state](../derived-state/) above it - is a closed, pure world: values -in, values out, no surprises. But applications exist to affect the world -outside that graph: paint pixels, play audio, talk to servers, listen to -sockets. This guide is about the edges of the graph - how data gets in, how it -gets out, and why Ember draws those edges where it does. +[derived state](../derived-state/) above it - is deliberately +self-contained: values in, values out, no side effects. But applications +exist to affect the world outside that graph: paint pixels, play audio, talk +to servers, listen to sockets. This guide is about the edges of the graph - +how data gets in, how it gets out, and why Ember draws those edges where it +does. The shape to keep in mind: -- **Inputs** write to root state: event handlers, response callbacks, +- Inputs write to root state: event handlers, response callbacks, subscription messages, timers. -- **Outputs** read the graph and act on the world. In Ember, the output is - the renderer; other side effects are _managed effects_, covered below. +- Outputs read the graph and act on the world. In Ember, the output is the + renderer; other side effects are managed effects, covered below. Everything in between stays pure. @@ -21,8 +22,8 @@ reactive systems - and work back around to inputs. In some reactive systems, you wire outputs yourself with an _effect_ primitive: a function that re-runs whenever the reactive values it read -change. If you ask "where is Ember's effect primitive?", the first answer is: -**you've been using it all along - it's the renderer.** +change. If you ask "where is Ember's effect primitive?", the first answer is +that you've been using it all along - it's the renderer. A template is a declaration of effects: every `{{expression}}` and every attribute binding is a tiny "when this value changes, update that DOM" rule. @@ -37,36 +38,36 @@ changed. The second answer is that the omission is deliberate, and the reasons are instructive: -- **Effects are eager.** An effect must re-run on every change to its inputs, +- Effects are eager. An effect must re-run on every change to its inputs, whether or not anyone needs the result, breaking the lazy, pull-based economics that make the rest of the system cheap. -- **Effect ordering is undefined.** When one change triggers several effects, - the execution order is unspecified. Correctness that depends on effect - order is a latent bug. -- **Effects that write state are a trap.** The most common effect mistake is - using one to _sync_ state: "when X changes, update Y." Now Y is stale until +- Effect ordering is undefined. When one change triggers several effects, the + execution order is unspecified. Correctness that depends on effect order is + a latent bug. +- Effects that write state are a trap. The most common effect mistake is + using one to sync state: "when X changes, update Y." Now Y is stale until the effect runs, can disagree with X, and if the effect's write triggers another effect, you have a cascade or an infinite loop. The answer is to - _derive, don't sync_: - in Ember, [the getter was the answer all along](../derived-state/), and the + derive instead of syncing: in Ember, + [the getter was the answer all along](../derived-state/), and the backtracking assertion makes write-during-derivation a loud error rather than a quiet bug. -- **Almost every "effect" is something more specific.** Look closely at real - effect code and you find: derived values (should be getters), DOM - manipulation (should be scoped to an element), and lifecycle-bound processes - like subscriptions (should be tied to an owner's lifetime, with cleanup). - Ember provides each of those as a dedicated, managed construct instead of - one general escape hatch. +- Almost every "effect" is something more specific. Look closely at real + effect code and you find derived values (should be getters), DOM + manipulation (should be scoped to an element), and lifecycle-bound + processes like subscriptions (should be tied to an owner's lifetime, with + cleanup). Ember provides each of those as a dedicated, managed construct + instead of one general escape hatch. ## Managed Effects: Attached to a Lifetime -Rendering is the only true _output_ in Ember - but it isn't the only side -effect. When you do need to act on the world yourself, Ember's tools all share -one design: the effect is attached to something with a _lifetime_, runs with -cleanup, and re-runs through the same autotracking as everything else. +Rendering is the only true output in Ember - but it isn't the only side +effect. When you do need to act on the world yourself, Ember's tools all +share one design: the effect is attached to something with a lifetime, runs +with cleanup, and re-runs through the same autotracking as everything else. -**Modifiers** are effects scoped to a DOM element. They run when the element -is rendered, re-run (after cleanup) when tracked state they consumed changes, +Modifiers are effects scoped to a DOM element. They run when the element is +rendered, re-run (after cleanup) when tracked state they consumed changes, and clean up when the element goes away: ```js {data-filename=app/modifiers/draw-chart.js} @@ -93,7 +94,7 @@ cannot run before there's an element, cannot leak after the element is gone, and declares in the template exactly where its impact lands. See [Template Lifecycle, DOM, and Modifiers](../../../components/template-lifecycle-dom-and-modifiers/). -**Destroyables** cover lifecycle-bound work with no element. Anything with an +Destroyables cover lifecycle-bound work with no element. Anything with an owner - components, services, helpers - can pair setup with guaranteed teardown via `registerDestructor` from [`@ember/destroyable`](https://api.emberjs.com/ember/release/modules/@ember%2Fdestroyable): @@ -119,9 +120,9 @@ export default class ClockService extends Service { ``` Any getter, anywhere in the app, can now derive from `clock.now` - "seconds -remaining," "is the store open," a formatted timestamp - and every one of them -updates each second, while the actual side effect (one interval, one cleanup) -stays in one place. +remaining," "is the store open," a formatted timestamp - and every one of +them updates each second, while the actual side effect (one interval, one +cleanup) stays in one place. This setup-plus-cleanup-plus-reactive-state package is commonly called a _resource_. In the Ember ecosystem, the @@ -143,23 +144,23 @@ this.socket.addEventListener('message', (event) => { ``` Writes from the outside are always safe. The backtracking assertion only -restricts writes _during_ a reactive computation (inside getters and -templates). An event callback runs outside any computation, so it can write as -much as it likes, and all the writes coalesce into a single rerender. +restricts writes during a reactive computation (inside getters and +templates). An event callback runs outside any computation, so it can write +as much as it likes, and all the writes coalesce into a single rerender. The clock service above is the full input pattern in miniature: an external process (the interval) feeds the graph through one tracked write, and cleanup -is bound to a lifetime. Subscriptions, `ResizeObserver`s, `BroadcastChannel`s - -they all take this shape: **subscribe with cleanup; on each notification, -write root state; derive everything else.** +is bound to a lifetime. Subscriptions, `ResizeObserver`s, +`BroadcastChannel`s - they all take the same shape. Subscribe with cleanup, +write root state on each notification, and derive everything else. ## Async: Tracking Stops at `await` -Tracking contexts are synchronous. The system records reads that happen -_while_ a template expression or cached getter is computing - and a -computation, in JavaScript, ends at the first `await`. Code after an `await` -(or inside `setTimeout`, or a `.then()` callback) runs later, outside the -computation that started it, so nothing it reads is consumed: +Tracking contexts are synchronous. The system records reads that happen while +a template expression or cached getter is computing - and a computation, in +JavaScript, ends at the first `await`. Code after an `await` (or inside +`setTimeout`, or a `.then()` callback) runs later, outside the computation +that started it, so nothing it reads is consumed: ```js // 🛑 The renderer cannot see through this @@ -168,10 +169,10 @@ get userName() { } ``` -Derivations must be synchronous. The reactive way to handle async work follows -from the input rule above: the _request_ is a side effect; its _progress_ is -root state. Run the effect at a lifetime boundary, and write each phase of it -into tracked properties: +Derivations must be synchronous. The reactive way to handle async work +follows from the input rule above: the request is a side effect, and its +progress is root state. Run the effect at a lifetime boundary, and write each +phase of it into tracked properties: ```gjs {data-filename=app/components/profile.gjs} import Component from '@glimmer/component'; @@ -217,24 +218,23 @@ export default class Profile extends Component { } ``` -Once async state is _data_, it stops being a special case: "show a spinner +Once async state is data, it stops being a special case: "show a spinner while pending" is just another derivation, the same `{{#if}}` as anything else. This "reactive promise" shape - status, value, and error as reactive fields - is available ready-made from libraries like [ember-resources](https://github.com/NullVoxPopuli/ember-resources) and -[WarpDrive](https://docs.warp-drive.io/)'s request state - or in a dozen lines -of your own, as above. +[WarpDrive](https://docs.warp-drive.io/)'s request state - or in a dozen +lines of your own, as above. Note one limitation of the example as written: the request is created in a field initializer, so it captures `userId` once and won't re-fetch if the argument changes - the construction-time snapshot trap described in [Deferring Consumption](../derived-state/#toc_deferring-consumption). -Re-running an effect when its reactive inputs change is -exactly the job of the managed constructs from earlier - a modifier (if -there's a sensible element) or a resource. That's the general rule of this -guide closing the loop: **when an effect needs to respond to the graph, give -it a lifetime the framework manages; when the world needs to update the graph, -write root state.** +Re-running an effect when its reactive inputs change is exactly the job of +the managed constructs from earlier - a modifier (if there's a sensible +element) or a resource. When an effect needs to respond to the graph, give it +a lifetime the framework manages; when the world needs to update the graph, +write root state. ## Choosing the Right Edge diff --git a/guides/release/in-depth-topics/reactivity/root-state.md b/guides/release/in-depth-topics/reactivity/root-state.md index 928d107f3c..8e54ee6c93 100644 --- a/guides/release/in-depth-topics/reactivity/root-state.md +++ b/guides/release/in-depth-topics/reactivity/root-state.md @@ -1,5 +1,5 @@ Root state is the foundation of the reactive graph: the values that change -_directly_, rather than being computed from something else. Everything else in +directly, rather than being computed from something else. Everything else in your application - derived values, rendered DOM - is a consequence of root state. That makes designing your root state the highest-leverage decision you make when managing UI state. Get it right, and the rest of your code becomes @@ -16,21 +16,20 @@ class Draft { } ``` -A write to a tracked property is the _only_ way anything changes in a reactive -application. Every update you see on screen traces back to some event handler, -timer, or response callback assigning to root state. +A write to a tracked property is the only way anything changes in a reactive +application. Every update you see on screen traces back to some event +handler, timer, or response callback assigning to root state. ## What Qualifies as Root State -A value belongs in root state only if _both_ of these are true: +A value belongs in root state only if both of these are true: -1. It changes over time, in response to the outside world (user input, network - responses, timers). +1. It changes over time, in response to the outside world (user input, + network responses, timers). 2. It cannot be computed from other state. -The second rule is the one that gets broken most often in practice, and it's -worth being strict about. For every tracked property, ask yourself: _could I -compute this instead?_ +The second rule is the one that gets broken most often in practice. For every +tracked property, ask yourself: _could I compute this instead?_ ```js class Cart { @@ -48,8 +47,9 @@ class Cart { The tracked `itemCount` looks harmless, but it creates a second source of truth. Every code path that changes `items` must now also remember to update -`itemCount`, forever. Once two copies of the truth exist, they will eventually -disagree - and that whole class of bug simply doesn't exist for the getter. +`itemCount`, forever. Once two copies of the truth exist, they will +eventually disagree - and that whole class of bug simply doesn't exist for +the getter. Storing derived values is sometimes pitched as an optimization. However, autotracking already recomputes lazily, and only when inputs change, so the @@ -57,16 +57,15 @@ optimization is usually imaginary. When a derivation really is expensive, you can [cache it](../derived-state/#toc_caching) instead of promoting it to root state. -A useful instinct: a well-factored reactive -application has surprisingly _little_ root state. A search page might have -exactly two root values - the query string and the raw results - while -everything else on screen (filtered lists, counts, empty-state flags, disabled -buttons) is derived. +As a rule of thumb, a well-factored reactive application has surprisingly +little root state. A search page might have exactly two root values - the +query string and the raw results - while everything else on screen (filtered +lists, counts, empty-state flags, disabled buttons) is derived. ## Root State Is Not a Cache for Someone Else's Truth -A special case of "could I compute this instead?" arises with _data that -arrives from elsewhere_: arguments passed to your component, records from your +A special case of "could I compute this instead?" arises with data that +arrives from elsewhere: arguments passed to your component, records from your data layer, the current route. The reactive system already tracks these. Copying them into your own tracked properties creates the synchronization problem all over again: @@ -91,24 +90,23 @@ class UserCard extends Component { ``` If you genuinely need "the argument, until the user edits it locally" (a form -draft, for example), that's _new_ root state whose initial value happens to -come from elsewhere. Create it explicitly in response to a user action, not by -mirroring the argument on every change. The +draft, for example), that's new root state whose initial value happens to +come from elsewhere. Create it explicitly in response to a user action, not +by mirroring the argument on every change. The [Patterns for Components](../../patterns-for-components/) guide shows several shapes of this. ## Writes, Equality, and Dirtying -When does a write actually invalidate things? By default, the answer is -simple: _every_ assignment to a tracked property dirties it, even if you -assign the value it already had. +When does a write actually invalidate things? By default, every assignment to +a tracked property dirties it, even if you assign the value it already had. ```js this.count = this.count; // still invalidates everything that consumed `count` ``` -Consumers re-evaluate, and the renderer re-checks the DOM it produced. The DOM -itself won't change if the final values are equal, but the recomputation +Consumers re-evaluate, and the renderer re-checks the DOM it produced. The +DOM itself won't change if the final values are equal, but the recomputation happens. When a write that changes nothing shouldn't dirty anything, the decorator @@ -130,38 +128,39 @@ class Player { Without options, `@tracked` keeps its historical always-dirty behavior, so existing code is unaffected. -When is custom equality worth it? The default is fine for most state - a -write usually happens because something actually changed. Custom equality -pays off when writes frequently _don't_ change the value: - -- **High-frequency events that usually land on the same value.** Writing the - current scroll direction (`'up'` or `'down'`) on every scroll event, or the - current breakpoint name on every resize: the event fires constantly, but - the value only changes at the moment of reversal or crossing. -- **Data that is re-fetched but rarely different.** Polling a server returns - a fresh object every time. By reference it is always "new"; by content it - is almost always the same as last time. -- **Value-like objects.** Dates, durations, coordinates: two distinct - instances can represent the same value. Comparing by content - for example, - `(a, b) => a.getTime() === b.getTime()` for dates - reflects what the value - _means_ rather than where it lives in memory. +Most state doesn't need custom equality, because a write usually happens +when something actually changed. It pays off when writes frequently don't +change the value: + +- High-frequency events that usually land on the same value. For instance, + writing the current scroll direction (`'up'` or `'down'`) on every scroll + event, or the current breakpoint name on every resize: the event fires + constantly, but the value only changes at the moment of reversal or + crossing. +- Data that is re-fetched but rarely different. Polling a server returns a + fresh object every time. By reference it is always "new"; by content it is + almost always the same as last time. +- Value-like objects such as dates, durations, and coordinates, where two + distinct instances can represent the same value. Comparing by content - for + example, `(a, b) => a.getTime() === b.getTime()` for dates - reflects what + the value means rather than where it lives in memory. In each of these cases, without an equality check every write invalidates every consumer, and the renderer re-evaluates everything downstream just to conclude that nothing changed. An equality check cuts that work off at the -root. The flip side: the check itself runs on every write, so for values that -really do change on most writes, the default is the cheaper choice. +root. On the other hand, the check itself runs on every write, so for values +that really do change on most writes, the default is the cheaper choice. -Because dirtying is per-property, the _granularity_ of your root state +Because dirtying is per-property, the granularity of your root state determines the granularity of updates. Three tracked properties invalidate independently; one tracked object replaced wholesale invalidates everything that read any part of it. Reactive systems thrive on fine-grained changes: prefer shapes that let you write exactly the piece that changed, and keep the -_identity_ of everything else stable. +identity of everything else stable. ## Mutable Data: Track the Collection -`@tracked` tracks _assignments to the property_, not mutations inside the +`@tracked` tracks assignments to the property, not mutations inside the value. Pushing into a plain array or setting a key on a plain object is invisible to the system: @@ -177,7 +176,7 @@ class ShoppingList { The right tool here is a tracked collection from [`@ember/reactive/collections`](https://api.emberjs.com/ember/release/modules/@ember%2Freactive%2Fcollections), -which tracks reads and writes of its _contents_ at fine granularity: +which tracks reads and writes of its contents at fine granularity: ```js import { trackedArray } from '@ember/reactive/collections'; @@ -193,16 +192,16 @@ class ShoppingList { Note that the property itself no longer needs `@tracked`. The collection carries its own reactivity, and the property is never reassigned. Two details -worth knowing: the collection functions _copy_ the data you pass in, so -mutating the tracked collection never mutates the original; and tracked -collections are shallow - `trackedObject`'s properties are tracked, but -objects stored _inside_ it are ordinary objects unless you wrap them too. See +to know: the collection functions copy the data you pass in, so mutating the +tracked collection never mutates the original; and tracked collections are +shallow - `trackedObject`'s properties are tracked, but objects stored inside +it are ordinary objects unless you wrap them too. See [Autotracking In-Depth](../../autotracking-in-depth/#toc_plain-old-javascript-objects-pojos) for the full tour of `trackedObject`, `trackedArray`, `trackedMap`, and `trackedSet`. -You may also see code that _replaces_ the value instead, assigning a -brand-new array to the tracked property: +You may also see code that replaces the value instead, assigning a brand-new +array to the tracked property: ```js addItem = (item) => { @@ -214,16 +213,16 @@ The assignment is a tracked write, so this updates - but it is the coarsest change there is. Every consumer of `items` invalidates, even ones that only cared about one entry, and the array's identity changes on every update, which defeats downstream `===` checks and caches (see -[stable identity](../derived-state/#toc_caching)). "One item changed" becomes -"everything changed." Retain identity wherever possible: keep one long-lived -tracked collection and mutate it, so the system invalidates only what -actually changed. Replacement is best reserved for genuinely value-like data -- a string, a date, a small tuple - where the new value simply _is_ a -different value. +[stable identity](../derived-state/#toc_caching)). Something that was really +a one-item change gets treated as if the entire array were new. Retain +identity wherever possible: keep one long-lived tracked collection and mutate +it, so that the system invalidates only what actually changed. Replacement is +best reserved for genuinely value-like data - a string, a date, a small +tuple - where the new value simply is a different value. ## Keep Root State Private, Expose Meaning -Root state is an implementation detail. The code that _uses_ your state +Root state is an implementation detail. The code that uses your state shouldn't know (or care) which parts are stored and which are computed. A good pattern is to keep reactive storage private and expose a domain-shaped public API: @@ -235,7 +234,7 @@ export class Cart { // Root state: private, mutable, reactive #items = trackedMap(); - // Public API: domain-shaped, read-only, derived + // Public API: read-only, derived get items() { return Array.from(this.#items.values()); } @@ -248,7 +247,7 @@ export class Cart { return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0); } - // Mutations: named after what they mean, not how they're stored + // Mutations, named for what they do to the cart add(product, quantity = 1) { this.#items.set(product.id, { ...product, quantity }); } @@ -262,25 +261,22 @@ export class Cart { Consumers read `cart.total` and call `cart.add(product)` - ordinary JavaScript, fully reactive, with no way to corrupt the internal storage. If you later change how items are stored, nothing outside the class notices. -Classes like this need no framework machinery at all; they work in components, -services, route models, and plain unit tests alike. +Classes like this need no framework machinery at all; they work in +components, services, route models, and plain unit tests alike. ## Classes Are Root State, Too Step back and look at what `Cart` is: tracked storage plus the getters -derived from it, bundled behind one reference. From the outside, an _instance_ +derived from it, bundled behind one reference. From the outside, an instance of `Cart` is a single reactive value - root state that happens to be non-primitive. A tracked property can hold a number or a string; it can just as well hold a `Cart`. That gives you two levels of granularity, and the dirtying rule from earlier -applies to both: - -- **Mutate the instance** - call `cart.add(product)` - and only consumers of - the affected internal state invalidate. -- **Replace the instance** - assign a new `Cart` to a tracked property - and - everything that read any part of it invalidates. That's a "reset all" in a - single write: +applies to both. Mutating the instance (calling `cart.add(product)`) +invalidates only the consumers of the affected internal state. Replacing the +instance (assigning a new `Cart` to a tracked property) invalidates +everything that read any part of it - a single write that resets everything: ```js import Component from '@glimmer/component'; @@ -350,7 +346,7 @@ pattern - effects tied to a lifetime - in depth. One more design rule for state classes: when a class needs to read state that lives somewhere else (component arguments, another class's tracked fields), -have it accept _functions_ rather than values, so that it reads the current +have it accept functions rather than values, so that it reads the current state on every use instead of a snapshot from construction time. See [Deferring Consumption](../derived-state/#toc_deferring-consumption) for the full pattern. @@ -371,11 +367,11 @@ count.value = 1; // writing invalidates consumers ``` Unlike the decorator, a standalone value checks equality by default (using -`Object.is`), so assigning the value it already holds invalidates nothing. You -can pass your own comparison with `tracked(initial, { equals })` - useful in -exactly the situations described in -[Writes, Equality, and Dirtying](#toc_writes-equality-and-dirtying) above. For -example, a poll that returns a fresh object every time: +`Object.is`), so assigning the value it already holds invalidates nothing. +You can pass your own comparison with `tracked(initial, { equals })`, which +is useful in exactly the situations described in +[Writes, Equality, and Dirtying](#toc_writes-equality-and-dirtying) above. +For example, a poll that returns a fresh object every time: ```js import { tracked } from '@glimmer/tracking'; @@ -395,8 +391,8 @@ serverStatus.value = await fetchStatus(); ``` Beyond `.value` there are function shorthands: `get()` and `set(value)`, plus -`update((current) => next)` - which writes based on the current value -_without_ consuming it - and `freeze()`, which prevents all further writes. +`update((current) => next)` - which writes based on the current value without +consuming it - and `freeze()`, which prevents all further writes. For application code, classes with `@tracked` remain the primary tool. The function form fills the gaps a decorator can't reach: function-based helpers @@ -431,16 +427,16 @@ for historical compatibility). Root state needs an owner - something whose lifetime matches the state's lifetime: -- **Component state** belongs on the component, or on plain classes the - component creates. It's created and thrown away with the component instance. - See [Component State and Actions](../../../components/component-state-and-actions/). -- **Application-wide state** belongs in a [service](../../../services/), which +- Component state belongs on the component, or on plain classes the component + creates. It's created and thrown away with the component instance. See + [Component State and Actions](../../../components/component-state-and-actions/). +- Application-wide state belongs in a [service](../../../services/), which lives as long as the application and can be injected anywhere. -- **URL-driven state** (the current route, query params) belongs in the - router. Reach for it via route models and query params rather than copying - it into tracked properties. +- URL-driven state (the current route, query params) belongs in the router. + Reach for it via route models and query params rather than copying it into + tracked properties. -One place root state should generally _not_ live is module scope: +One place root state should generally not live is module scope: ```js // 🛑 Avoid in apps From 67ae08b550ba7c06b05ffee6112a6ce4ed0204f6 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:02:42 -0400 Subject: [PATCH 7/8] Feedback --- .../in-depth-topics/autotracking-in-depth.md | 2 +- .../reactivity/derived-state.md | 10 ++--- .../in-depth-topics/reactivity/index.md | 6 +-- ...outside-world.md => inputs-and-outputs.md} | 0 .../in-depth-topics/reactivity/root-state.md | 39 ++++++++++++++++--- guides/release/pages.yml | 4 +- 6 files changed, 45 insertions(+), 16 deletions(-) rename guides/release/in-depth-topics/reactivity/{outside-world.md => inputs-and-outputs.md} (100%) diff --git a/guides/release/in-depth-topics/autotracking-in-depth.md b/guides/release/in-depth-topics/autotracking-in-depth.md index 2f68ad1e96..f40e2e99d1 100644 --- a/guides/release/in-depth-topics/autotracking-in-depth.md +++ b/guides/release/in-depth-topics/autotracking-in-depth.md @@ -199,7 +199,7 @@ This will also trigger a rerender. No matter where the update occurs, updating a tracked property will let Ember know to rerender any affected portion of the app. Writing to tracked state from callbacks like this is the standard way data from the outside world enters Ember's reactivity system - see -[Reactivity and the Outside World](../reactivity/outside-world/) for more on +[Inputs and Outputs](../reactivity/inputs-and-outputs/) for more on this pattern. ### Tracking Through Methods diff --git a/guides/release/in-depth-topics/reactivity/derived-state.md b/guides/release/in-depth-topics/reactivity/derived-state.md index 564974bc41..43fcb42d84 100644 --- a/guides/release/in-depth-topics/reactivity/derived-state.md +++ b/guides/release/in-depth-topics/reactivity/derived-state.md @@ -71,7 +71,7 @@ One consequence is that you should never rely on when, or whether, a getter runs. A derivation may run once, many times, or never; it may run later than you expect or more often than you expect. If you find yourself wanting "run this code when X changes," you are looking for something other than derived -state - see [Reactivity and the Outside World](../outside-world/). +state - see [Inputs and Outputs](../inputs-and-outputs/). ## Derivations Must Be Pure @@ -86,10 +86,10 @@ Error: You attempted to update `count`, but it had already been used previously in the same computation. ``` -This is sometimes called the _backtracking assertion_: render evaluates your -derivations top to bottom, and a write partway through would invalidate -output that was already produced. When you hit this error, restructure the -code so the write isn't needed: +This error is the _backtracking assertion_: render evaluates your derivations +top to bottom, and a write partway through would invalidate output that was +already produced. When you hit it, restructure the code so the write isn't +needed: ```js // 🛑 Don't: a "derivation" that pushes its result somewhere else diff --git a/guides/release/in-depth-topics/reactivity/index.md b/guides/release/in-depth-topics/reactivity/index.md index 0cefee2916..ed2ac48130 100644 --- a/guides/release/in-depth-topics/reactivity/index.md +++ b/guides/release/in-depth-topics/reactivity/index.md @@ -119,7 +119,7 @@ a callback that runs later - after an `await`, or inside a `setTimeout` - that read happens outside any tracking context, and nothing is consumed. This is rarely a problem in practice, since templates, getters, and helpers are all synchronous, but it explains a whole class of "why didn't this update?" -bugs. The [Reactivity and the Outside World](./outside-world/) guide covers +bugs. The [Inputs and Outputs](./inputs-and-outputs/) guide covers this boundary in detail. ## Pull, Not Push @@ -151,7 +151,7 @@ you don't know the model: `tax` has updated but `subtotal` hasn't. - There is no "re-run this code when X changes" primitive. In a push-based system you might reach for an _effect_ for that. Ember deliberately doesn't - offer one; the [Reactivity and the Outside World](./outside-world/) guide + offer one; the [Inputs and Outputs](./inputs-and-outputs/) guide explains why, and what to do instead. ## Where to Go from Here @@ -162,7 +162,7 @@ The rest of this section works through each layer of the model: and how to design it. - [Derived State](./derived-state/) - laziness, purity, caching, composition, and deferring consumption. -- [Reactivity and the Outside World](./outside-world/) - outputs, side +- [Inputs and Outputs](./inputs-and-outputs/) - inputs, outputs, side effects, async, and the edges of the graph. For the mechanics of `@tracked` itself - updating, custom classes, arrays and diff --git a/guides/release/in-depth-topics/reactivity/outside-world.md b/guides/release/in-depth-topics/reactivity/inputs-and-outputs.md similarity index 100% rename from guides/release/in-depth-topics/reactivity/outside-world.md rename to guides/release/in-depth-topics/reactivity/inputs-and-outputs.md diff --git a/guides/release/in-depth-topics/reactivity/root-state.md b/guides/release/in-depth-topics/reactivity/root-state.md index 8e54ee6c93..aa046fd3d8 100644 --- a/guides/release/in-depth-topics/reactivity/root-state.md +++ b/guides/release/in-depth-topics/reactivity/root-state.md @@ -125,8 +125,19 @@ class Player { } ``` -Without options, `@tracked` keeps its historical always-dirty behavior, so -existing code is unaffected. +Without options, `@tracked` has always-dirty behavior. + +
+
+
+
Zoey says...
+
+ @tracked({ equals }) and the tracked() function described later on this page are new in Ember 7.3. +
+
+ +
+
Most state doesn't need custom equality, because a write usually happens when something actually changed. It pays off when writes frequently don't @@ -341,7 +352,7 @@ export default class Dashboard extends Component { `registerDestructor` gives `Poller` its cleanup; `associateDestroyableChild` links it to the component, so when the component is destroyed, the poller is destroyed with it. The -[Reactivity and the Outside World](../outside-world/) guide covers this +[Inputs and Outputs](../inputs-and-outputs/) guide covers this pattern - effects tied to a lifetime - in depth. One more design rule for state classes: when a class needs to read state that @@ -368,8 +379,26 @@ count.value = 1; // writing invalidates consumers Unlike the decorator, a standalone value checks equality by default (using `Object.is`), so assigning the value it already holds invalidates nothing. -You can pass your own comparison with `tracked(initial, { equals })`, which -is useful in exactly the situations described in +This is the one place the two forms of `tracked` disagree, and it's easy to +trip over. The same-looking write behaves differently in each: + +```js +class Counter { + @tracked count = 0; +} +let counter = new Counter(); +counter.count = 0; // dirties: the decorator does not check equality + +const count = tracked(0); +count.value = 0; // does nothing: the value is already 0 +``` + +If you need the two forms to match, give the decorator an equality check +(`@tracked({ equals: Object.is })`), or opt the standalone value out of +checking with `equals: () => false`. + +You can also pass your own comparison with `tracked(initial, { equals })`, +which is useful in exactly the situations described in [Writes, Equality, and Dirtying](#toc_writes-equality-and-dirtying) above. For example, a poll that returns a fresh object every time: diff --git a/guides/release/pages.yml b/guides/release/pages.yml index 41bb8722eb..d7d39c40de 100644 --- a/guides/release/pages.yml +++ b/guides/release/pages.yml @@ -161,8 +161,8 @@ url: "root-state" - title: "Derived State" url: "derived-state" - - title: "Reactivity and the Outside World" - url: "outside-world" + - title: "Inputs and Outputs" + url: "inputs-and-outputs" - title: "Patterns for Components" url: "patterns-for-components" - title: "Patterns for Actions" From ffd867a227eeba8afed39f2531803219f81a9d21 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:02:56 -0400 Subject: [PATCH 8/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- guides/release/in-depth-topics/reactivity/root-state.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/release/in-depth-topics/reactivity/root-state.md b/guides/release/in-depth-topics/reactivity/root-state.md index aa046fd3d8..09d089aac8 100644 --- a/guides/release/in-depth-topics/reactivity/root-state.md +++ b/guides/release/in-depth-topics/reactivity/root-state.md @@ -187,7 +187,7 @@ class ShoppingList { The right tool here is a tracked collection from [`@ember/reactive/collections`](https://api.emberjs.com/ember/release/modules/@ember%2Freactive%2Fcollections), -which tracks reads and writes of its contents at fine granularity: +which tracks reads and writes of its contents: ```js import { trackedArray } from '@ember/reactive/collections';