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"