← Back to Blog

reactiveUtils in Maps SDK JavaScript — watch, when, whenOnce, and the handle you forgot

A single Accessor property on the left feeding four colored observer wires to the right — watch fires on every change, when fires only when the value becomes truthy, whenOnce resolves once as a Promise, and on relays a DOM-style event — each ending in a handle whose remove() call is highlighted

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.

FunctionFires whenReturnsReach 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 truetrue 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.

Undocumented exports — know they exist, do not build on them. The compiled module also exports 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 falsetruefalse, 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.

Watching a Collection needs a derived value — a real array. Subscribing to a Collection itself rarely does what people expect. Return a derived value from the expression instead, and make sure it is a plain array: () => 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.

  1. 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.
  2. Prefer whenOnce to a watch with { once: true } when you can. Both are correct. The Promise version is easier to compose with Promise.all, easier to time out with AbortSignal.timeout, and disappears from your handle-tracking code, which is a place where fewer things is better than more.
  3. 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 watch callback that would echo the change back into itself. Keep a let muted = false in 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 undocumented pausable export 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.
If you are not the one writing this code. Every branded map application you own ships with a handful of these subscriptions. If they leak, the leak is invisible on a laptop with a hot cache and a fresh browser, and gradually visible in the tickets that say “the map slowed down after I left it open for an hour.” The fix is cheap when it is a review-time habit and expensive when it is a memory-profile investigation six months in. Ask your team where their reactiveUtils handles are removed. If the answer is “we do not remove them,” the review-time habit is worth adding this week.

References

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