Concorde documentation (crawl) · interactive version

Concorde decorator — @handle. Typed callback on one or more DataProviderKey<T> paths: invokes the decorated method when a publisher assigns a value (calculations, side effects, updating other @state properties, etc.). Doc ID: docs/_decorators/handle. Keywords: Concorde, supersoniks, docs/_decorators/handle, handle, @handle, decorator, DataProviderKey, @state, HandleOptions, null, undefined, @bind, @subscribe, ${user.firstName} ${user.lastName}. URL: https://concorde.supersoniks.org/crawl/docs/_decorators/handle.html.

@handle

Typed callback on one or more DataProviderKey<T> paths: invokes the decorated method when a publisher assigns a value (calculations, side effects, updating other @state properties, etc.).

Unlike @subscribe, nothing is bound to the decorated member — only your method runs. @handle is typed and accepts up to 3 keys plus an optional trailing HandleOptions object. It supersedes the string-based @onAssign.

By default the method is called on every assignment, even when the value is null / undefined. Use the options below to restrict that.

Import

import { handle, Skip } from "@supersoniks/concorde/decorators";
import { DataProviderKey } from "@supersoniks/concorde/dataProviderKey";
import { get, set } from "@supersoniks/concorde/utils";

Basic example

type DemoCounterData = { count: number };
const demoDataKey = new DataProviderKey&lt;DemoCounterData&gt;("demoData");

@customElement("demo-handle")
export class DemoHandle extends LitElement {
  @state() doubled = 0;
  @state() lastUpdate = "";

  @handle(demoDataKey.count)
  onCountChange(count: number) {
    this.doubled = count * 2;
    this.lastUpdate = new Date().toLocaleTimeString();
  }

  incrementCount() {
    const data = get(demoDataKey);
    set(demoDataKey, { ...data, count: data.count + 1 });
  }

  render() {
    return html`
      &lt;p&gt;Doubled count: ${this.doubled}&lt;/p&gt;
      &lt;p&gt;&lt;small&gt;Last update: ${this.lastUpdate}&lt;/small&gt;&lt;/p&gt;
      &lt;sonic-button @click=${this.incrementCount}&gt;Increment&lt;/sonic-button&gt;
    `;
  }
}
<docs-demo-sources for="demo-handle"></docs-demo-sources>
    <demo-handle></demo-handle>

Dynamic path

Placeholders in DataProviderKey resolve from the host component’s properties (same rules as @bind / @subscribe).

type User = { firstName: string; lastName: string; email: string };

@customElement("demo-handle-dynamic")
export class DemoHandleDynamic extends LitElement {
  @property({ type: Number })
  userIndex = 0;

  @state() displayName = "";
  @state() lastUpdate = "";

  @handle(new DataProviderKey&lt;User, { userIndex: number }&gt;("demoUsers.${userIndex}"))
  onUserAssigned(user: User) {
    this.displayName = `${user.firstName} ${user.lastName}`;
    this.lastUpdate = new Date().toLocaleTimeString();
  }

  render() {
    return html`...`;
  }
}
<docs-demo-sources for="demo-handle-dynamic"></docs-demo-sources>
    <demo-handle-dynamic></demo-handle-dynamic>

Multiple paths

@handle accepts up to 3 keys; the method receives one strongly-typed argument per key, in order. Each assignment triggers the method, so make your method safe against partial values (or use waitForAllDefined, see below).

type QueueConfig = { onInactivity: { stillHere: { show: boolean } } };
const config = new DataProviderKey&lt;QueueConfig&gt;("sessionQueueConfig");
const idle = new DataProviderKey&lt;{ isIdle: boolean }&gt;("idleStatus");

@customElement("demo-handle-multi")
export class DemoHandleMulti extends LitElement {

  // show: boolean, isIdle: boolean — fully typed from the keys
  @handle(config.onInactivity.stillHere.show, idle.isIdle)
  onInactivity(show: boolean, isIdle: boolean) {
    if (show === true && isIdle === true) this.openModal();
    else this.closeModal();
  }
}

Options (HandleOptions)

Pass an options object as the last argument. The names map to real situations seen in the apps.

waitForAllDefined

Only call the method once all watched keys are defined (non null / undefined). This reproduces the historical @onAssign semantics — use it when the logic only makes sense with every source ready (e.g. building a date from date + timeZone + direction).

@handle(trip.departureDate, trip.event.timeZone, form.direction, {
  waitForAllDefined: true,
})
updateDepartureDate(date: number, timeZone: string, direction: string) {
  // called only when the three values are all available
  this.formDate = formatDate(date, timeZone, direction);
}

skip

Do not call the method when a received value belongs to one of the listed categories (the Skip enum). Each entry is a named category — not a value — so there is no value/pattern ambiguity (e.g. {} is Skip.EmptyObject, an explicit "empty object" category, never a value comparison).

Category Matches
Skip.Nullish null or undefined (a publisher always emits null, never undefined)
Skip.EmptyString ""
Skip.EmptyObject object with no keys ({}), arrays excluded
Skip.EmptyArray empty array ([])

Useful when a not-yet-initialized publisher emits {} as a "loading" state. For a specific value (e.g. a particular string), guard inside the method instead.

@handle(user.profile, { skip: [Skip.Nullish, Skip.EmptyObject] })
onProfile(profile: Profile) {
  // not called while the publisher still holds {} (not loaded yet)
  this.displayName = profile.firstName;
}

Options can be combined, e.g. @handle(a, b, { waitForAllDefined: true, skip: [Skip.Nullish] }). For any arbitrary validation on a specific value, just guard inside the method (if (!isValid(v)) return;) — that is exactly what an accept-style predicate would do, since @handle only runs your method.

skipEmptyPlaceholder

On a dynamic key path ("items.${itemId}"), if true, a placeholder resolved to '' is treated as not ready (no subscription) — empty string only, not 0 or false. See Dynamic path placeholders.

Highlights

See also DataProviderKey and Dynamic path placeholders.