reactiveUtils in Maps SDK JavaScript — watch, when, whenOnce, and the handle you forgot
Every ArcGIS Maps SDK for JavaScript app watches something. The layer view
finishes updating and the results panel un-dims. A tool becomes ready and its button enables.
The user zooms and the scale bar snaps to a new increment. Each of those is a subscription, and
since the SDK moved to the Accessor-based property system, the standard way to build them on the
core API is @arcgis/core/core/reactiveUtils.
The reference documents five functions — watch,
when, whenOnce, once
and on — and the temptation is to treat them as synonyms because
their argument lists rhyme. They are not synonyms. Mixing them up is the difference between a
listener that fires every time, one that fires exactly once, and one that never fires again after
the first time the value is true. This is the working reference for which one to reach for, the two
ways a subscription fails, and the leak every long-running app eventually has to fix.
The surface, in one table
Every function that returns a handle returns the same shape: a
ResourceHandle, an object with a remove()
method. Every function that returns a Promise accepts an optional cancellation argument instead.
That distinction is not decorative — it decides how you clean up.
| Function | Fires when | Returns | Reach for it when |
|---|---|---|---|
watch |
The tracked value changes, on every change | Handle | The consumer needs to react to both directions — true to false and back |
when |
The tracked value becomes truthy — a falsy → truthy
transition, never true → true |
Handle | A condition crosses a threshold, repeatedly — but see the already-true trap below |
whenOnce |
The tracked value is truthy — now, or the first time it becomes so | Promise | You are inside an async function and just need to await a ready state |
once |
The tracked value changes for the first time | Promise | You want one change and then to be done, without a filter |
on |
An event fires on an Evented target |
Handle | The source emits DOM-style events (a view click, a layer refresh) |
Two behaviors that are easy to miss and are documented on that same page. First,
watch, when and
on are asynchronous by default: the callback runs on
the next microtask after the value changes, not on the same tick. Change a property and read your
derived state on the very next line and you will read the old value. Pass
{ sync: true } when you genuinely need it synchronously.
Second, the default comparison is a shallow equality check, and it cuts both ways.
A getter returning a fresh array or plain object whose contents are unchanged compares
equal, so the callback does not fire — which surprises people who expect a
new reference to count as a change. A getter returning an Accessor instance — a
Collection, an Extent, a
Graphic — falls back to identity, so a freshly built one never compares equal and the
callback fires on every evaluation. Returning a primitive, or a plain array of primitives, avoids
both surprises.
pausable,
initial, sync,
syncAndInitial and autorun. They are real and
they work at 5.1 — the SDK uses several of them internally — but none of them
appears in the public reference, so none carries an API stability guarantee and any of them
can change or disappear in a minor release with no deprecation notice. pausable
in particular is a short wrapper over watch that gates the callback behind a
boolean; if you want that behavior, write the boolean yourself and keep your app on documented API.
Note also that the export named initial is a shared options object, not the
documented { initial: true } property used in every sample below — same
word, different things.Two ways a subscription fails, and only one of them is quiet
These get conflated constantly, and they call for opposite debugging moves. The first is loud and lands in your console. The second is genuinely silent and is the one worth learning to spot.
Failure one: you passed a value where a getter belongs. The first argument to
watch, when,
whenOnce and once is a
reactive expression — a getValue function, not a value.
// Wrong. Evaluates layerView.updating to a boolean, then hands that boolean // to watch as if it were a function. reactiveUtils.watch(layerView.updating, updating => setBusy(updating)); // Right. The first argument is a function. The tracking system runs it, records // which Accessor properties it read, and re-runs the callback when they change. reactiveUtils.watch(() => layerView.updating, updating => setBusy(updating));
This one does not fail silently. The tracking system invokes your expression
synchronously, at subscribe time, to discover its dependencies — so a boolean where a
function belongs throws a TypeError immediately, out of the
watch() call itself, with a stack trace pointing at the line. In
TypeScript it does not even compile: the parameter is typed as an expression function, so the
wrong argument is rejected before you run anything. If you are hunting a subscription that never
fires, this is not your bug — you would already know.
Failure two: your getter is a valid function that depends on nothing. This is the
quiet one. The expression runs, returns undefined, touches no Accessor
property, and therefore registers no dependency. Nothing throws. Nothing ever fires.
// Silent. A FeatureLayer has no `updating` property - the layer VIEW does. // The getter is valid, returns undefined, and depends on nothing. reactiveUtils.watch(() => layer.updating, (u) => setBusy(u)); // Right. `updating` lives on the layer view. const layerView = await view.whenLayerView(layer); reactiveUtils.watch(() => layerView.updating, (u) => setBusy(u));
Every misspelled property, every object that is not the one you meant, and every
not-yet-resolved reference lands in this class. The diagnostic is simple once you know the split:
if the console is clean and the callback never runs, log what your getter returns.
If it is undefined, you are watching nothing. And if you need to know
that a layer is loading its schema rather than that features are arriving, watch
layer.loadStatus — that one does exist on the layer.
watch is not when
The other frequent trap is reaching for when for a case that
actually needs watch. On paper they look alike. In practice they
differ in exactly one detail, and it is the detail that matters most.
watch fires on every change to the tracked value. If a boolean
goes false → true →
false, watch fires twice.
when filters those transitions to just the falsy-to-truthy ones. It
fires when the value becomes truthy. It does not fire when the value
subsequently goes back to falsy.
That asymmetry is what breaks the naive “dim the panel while the layer view is updating” pattern:
// Wrong. The panel dims when the layer view starts updating, and then never // un-dims because when() does not fire on true -> false. reactiveUtils.when(() => layerView.updating, () => setBusy(true)); // Right. watch() gives us both edges, and we forward the new value directly. reactiveUtils.watch(() => layerView.updating, (updating) => setBusy(updating));
There is a second half to that asymmetry, and it is the half that bites hardest. Because
when fires on a transition into truthy, it does not fire at all
if the value is already truthy when you subscribe. The reference states it plainly:
when() and whenOnce() “only trigger the callback when the expression changes and then the value
satisfies the expression, such as false -> true -> false, but not true -> true,” and it
warns to “be careful with initial and default property values.” Register
when(() => view.ready) after the view is already ready and your callback
never runs. Pass { initial: true }, or use
whenOnce, which resolves against the current value.
A related trap: when tests for truthiness, so it is the wrong
tool whenever a falsy value is legitimate. when(() => view.scale) and
when(() => count) both quietly ignore 0.
When a specific value is what you mean, compare explicitly —
when(() => layerView.updating === false, ...).
The heuristic that keeps this straight: if the state your consumer maintains has more than
one steady value it can rest in, you need watch. If it only cares
that a threshold was crossed — the view became stationary, a portal item finished loading,
a query result arrived — when is the right tool. And if you
only need to know that fact once and then move on, whenOnce gives
you a Promise you can await.
whenOnce and once, the Promise flavors
The two Promise-returning members are underused, and they turn a lot of subscription
bookkeeping into ordinary async code. whenOnce resolves when the
getter returns truthy — either immediately, if it already is, or the first time it becomes
so. once is the change-based cousin: it resolves the first time the
tracked value changes.
// Await the view being ready before doing anything with it. Also handles the // case where the view is already ready when this function is called. async function withView() { await reactiveUtils.whenOnce(() => view.ready); // ... now safe to query view.extent, view.scale, view.spatialReference }
The second argument to both whenOnce and
once is a cancellation argument, and it accepts either an
AbortSignal directly or an options object carrying one —
{ signal }, which is the form the reference’s own example uses. Pass one
from an AbortController and the Promise rejects when you call
controller.abort(). Check the rejection with
err.name === "AbortError" rather than an
instanceof test — the SDK raises its own error type, not a native
DOMException. That means you cancel a pending
whenOnce the same way you cancel a
fetch(), which is the right shape for a component teardown.
The handle you forgot — and the two-line pattern that fixes it
Every handle-returning observer must be removed. The framework does not know when your component or module has stopped caring, and the tracker holds a reference to the callback for as long as the handle is alive. A component that creates a handle on every mount and never removes it will happily hold a growing pile of dead closures in memory, still firing every time the tracked property changes, still touching state on components that no longer exist. React’s strict-mode double-mount makes this show up immediately in development. Vue’s hot-module-replace path shows it about as fast. Vanilla apps hide it best.
The correct pattern in a React function component is two lines — create the handle
inside useEffect, and return a cleanup that calls
remove:
import * as reactiveUtils from "@arcgis/core/core/reactiveUtils.js"; function BusyIndicator({ layerView }) { const [busy, setBusy] = useState(false); useEffect(() => { if (!layerView) return; const handle = reactiveUtils.watch( () => layerView.updating, (updating) => setBusy(updating), { initial: true } // fire once now with the current value ); return () => handle.remove(); // <-- the line every leaky app is missing }, [layerView]); return busy ? <Spinner /> : null; }
Two things about that snippet are load-bearing. The dependency array names
layerView because the callback closes over it — if
layerView is replaced (a new layer, a new view), the effect must
tear down the old subscription and create a new one. And the
initial: true option makes the callback fire once with the current
value, which is usually what you want in a UI: if the layer view is already updating when the
component mounts, you would like the spinner to be on immediately, not on the next transition.
When you have several handles to manage — watching
updating, listening for
layer.refresh events, and watching
view.scale from the same component — the tidy pattern is to
collect them and call remove on each:
useEffect(() => {
// The guard is not optional. Because the expression is evaluated at subscribe
// time, an undefined layerView throws here - and on first render it IS
// undefined, since it arrives from an awaited whenLayerView.
if (!layerView || !layer || !view) return;
const handles = [
reactiveUtils.watch(() => layerView.updating, (u) => setBusy(u)),
reactiveUtils.on(() => layer, "refresh", (e) => { if (e.dataChanged) refetch(); }),
reactiveUtils.watch(() => view.scale, (s) => setScale(s)),
];
return () => handles.forEach((h) => h.remove());
}, [layerView, layer, view]);
Two details there are worth stealing. The guard matters because a thrown error inside the effect
means handles is never assigned and the cleanup function is never
returned — the effect that exists to prevent a leak becomes the leak. And the
refresh event fires whenever layer.refresh()
is called or the refreshInterval elapses, including when nothing actually
changed, so gate the refetch on
dataChanged rather than refetching unconditionally.
If you are managing more than a couple of subscriptions, the SDK ships a
Handles class for exactly this — add handles under a group key and
remove the whole group at teardown. The array is fine for three; reach for
Handles when a component owns more.
The equivalent in vanilla JavaScript is a plain array on the module or class, cleared in a
dispose() method. The equivalent in Vue’s
setup is onScopeDispose(() =>
handles.forEach(h => h.remove())). The pattern is the same everywhere; only the hook
changes.
A real job — dimming a results panel while the layer view is updating
Consider a common branded-app pattern: an ArcGIS FeatureLayerView
backs a side panel of query results. Whenever the extent moves, the layer view refetches. While
that refetch is in flight, the panel should show a subtle loading state so the user does not
read a stale count. When the refetch settles, the panel should immediately return to normal.
The naive attempt is to listen for a click on the zoom controls or to hook into every
possible input event, and try to reason about when a query is likely to be running. That never
converges — users pinch, resize, rotate, and trigger refetches from code paths you did not
write. The correct signal is already on the layer view:
updating is a boolean that flips to
true whenever the layer view is fetching new data and back to
false when it settles.
import * as reactiveUtils from "@arcgis/core/core/reactiveUtils.js"; async function wireResultsPanel(view, layer, panel) { // Wait for the layer view to exist, then subscribe. const layerView = await view.whenLayerView(layer); const handle = reactiveUtils.watch( () => layerView.updating, (updating) => panel.classList.toggle("is-loading", updating), { initial: true } ); // Return the handle so the caller can dispose the panel later. return handle; }
Three details in that construction earn their keep. The
await view.whenLayerView(layer) call guarantees that
layerView exists before we reference it — and it is the call that
needs a rejection path, because it throws if the layer is not in the map or fails to load. Wrap it
in try/catch in real code rather than leaving an unhandled rejection in
a function this shape.
initial: true sets the panel to the correct state on the first
render, which matters when the app is opened at a URL that already carries an extent.
Returning the handle means the caller can put a
handle.remove() in whatever lifecycle it owns — a dialog’s
close, a route change, a page teardown — instead of leaking one subscription per session.
() => view.allLayerViews.map((lv) => lv.updating).toArray(). The
.toArray() matters, because Collection.map()
returns another Collection — and per the equality rule above, a
Collection is compared by identity, so a freshly built one never matches the previous value and your
callback fires on every evaluation. Convert to an array and the shallow compare does what you
want: fire when the contents differ, stay quiet when they do not.Cleanup patterns that actually work
Three that carry more mileage than they get credit for.
- Collect handles into an array in the same closure that creates them. The moment two subscriptions live in the same lifecycle scope, the array pattern is easier to reason about than named variables, and it forces the disposer to iterate rather than to remember which names to null out.
- Prefer
whenOnceto awatchwith{ once: true }when you can. Both are correct. The Promise version is easier to compose withPromise.all, easier to time out withAbortSignal.timeout, and disappears from your handle-tracking code, which is a place where fewer things is better than more. - Gate a subscription with a boolean rather than tearing it down and rebuilding
it. The classic case is a controlled input that dispatches its own value to the map and
needs to ignore the
watchcallback that would echo the change back into itself. Keep alet muted = falsein the same closure, check it at the top of the callback, and set it around your own write. That is a documented-API version of what the undocumentedpausableexport does internally, and it will not break when the module’s private surface changes. Note that a gated callback drops the changes it skips — nothing is replayed when you un-mute.
References
- reactiveUtils reference (the five documented functions, the async-by-default note and the truthy-check guidance)
- FeatureLayerView reference (the updating property this article uses as its worked example)
- MapView reference (whenLayerView, and the view lifecycle behind the results-panel example)
- Handles reference (the group-based alternative to a hand-rolled array of handles)
- Programming patterns for the Maps SDK (the Accessor-based reactivity model that underlies every observer here)
Auditing a codebase for subscription leaks?
We review ArcGIS Maps SDK codebases for the exact patterns this post describes — subscriptions that never fire, listeners that never detach, and the React and Vue teardown shapes that turn both classes of defect into a habit rather than a hunt. Book a free intro call and we will talk through what a review would find on your stack.
Book a free intro call