Titouan Mathis

CTO at Studio Meta and ikko

Scoped, reusable UI patterns with DataScope

27/07/2026 in #studiometa-ui #data-components

The Data components from @studiometa/ui (DataModel, DataBind, DataComputed and DataEffect) are built on the withGroup decorator from @studiometa/js-toolkit. withGroup collects every instance that shares a data-option-group into a set each member can reach through its $group getter, and the Data components use it to keep a value in sync across that group. The catch is that the group is resolved app-wide, so two UI patterns that happen to use the same group name land in the same set and share one value, so a second copy of a component on the page fights the first.

DataScope fixes that by using withGroup's scoping feature. Wrapping a subtree in a DataScope marks it as a scope, so descendant Data components resolve their group against that scope instead of app-wide: two scopes can reuse the same group name and never share values, and each scope's keyed values live in a frozen $data snapshot. The upshot is that you can assemble a reusable interactive UI pattern out of the primitives and drop two of them side by side without a collision.

Availability

DataScope ships with @studiometa/ui v1.9.0 and up, make sure you are on that release or newer before reaching for it.

Tabs without a Tabs component

A tab interface comes together from the primitives. A hidden DataModel holds the active tab as the single source of truth, seeded to overview with data-option-immediate; each tab button sets it on click and reads it back to show its selected state, and every panel is a DataBind that hides itself unless it is the active one. No Tabs class is registered, the behaviour is entirely declarative.

But put two of these on a page and, without a scope, they share the app-wide tabs group: switch a tab in one set and the other jumps with it.

Wrap each set in its own DataScope, reusing the same group name and active key, and they run independently:

Here is one set, condensed to the parts that matter:

<div data-component="DataScope" data-option-group="tabs">
  <div role="tablist" aria-label="Product details">
    <button
      type="button"
      role="tab"
      id="tab-overview"
      aria-controls="panel-overview"
      data-component="Action DataBind"
      data-option-key="active"
      data-on:click="DataModel(#tabs) -> target.set('overview')"
      data-bind:attr.aria-selected="String(value === 'overview')">
      Overview
    </button>
    <!-- …Specs, Reviews… -->
  </div>

  <input
    id="tabs"
    type="hidden"
    name="active"
    value="overview"
    data-component="DataModel"
    data-option-immediate />

  <div
    role="tabpanel"
    id="panel-overview"
    aria-labelledby="tab-overview"
    tabindex="0"
    data-component="DataBind"
    data-option-key="active"
    data-bind:attr.hidden="value !== 'overview'">
    A quick overview of the product.
  </div>
  <!-- …one panel per tab… -->
</div>

Going from the colliding version to this one is two edits: wrap the set in a DataScope, and swap the app-wide group for a scoped key.

-<div>
+<div data-component="DataScope" data-option-group="tabs">
   <button
     data-component="Action DataBind"
-    data-option-group="tabs"
+    data-option-key="active"
     data-on:click="DataModel(#tabs) -> target.set('overview')">
     Overview
   </button>
   <!-- …the hidden model and one DataBind panel per tab… -->
 </div>

Keyboard navigation

These tabs carry the full ARIA tab and tabpanel semantics, but they keep every tab in the Tab sequence instead of the WAI-ARIA Tabs pattern's roving tabindex and Arrow-key navigation, which needs imperative focus management. For a production widget, add that or reach for @studiometa/ui's Tabs.

A scoped form and $data

Forms have the same collision. These two share the app-wide person group with no scope, so typing in one field overwrites the other and both greetings track a single value:

Give each form its own DataScope and they are isolated. The scope also unlocks the $data snapshot: every keyed value in the group is exposed to DataComputed (and DataEffect), so a greeting can read first and last together, even though both forms use the same keys:

Here is the scoped markup for one form. The two inputs take their first and last keys from their name, and the computed greeting reads both off $data:

<div data-component="DataScope" data-option-group="person">
  <input
    name="first"
    value="Grace"
    aria-label="First name"
    data-component="DataModel"
    data-option-immediate />
  <input
    name="last"
    value="Hopper"
    aria-label="Last name"
    data-component="DataModel"
    data-option-immediate />

  Hello,
  <span data-component="DataComputed" data-option-compute="`${$data.first} ${$data.last}`.trim()">
    Grace Hopper
  </span>
  .
</div>

$data is read-only: its object and any array or Date values are frozen and cloned, so a snapshot can be passed around without a subscriber mutating shared state.

A quantity stepper and a line total

A cart line reads one number, the quantity, from several places. The Action stepper buttons call increment() on the qty model, and a DataComputed line total multiplies that value by the unit price. Without a scope, two lines share the app-wide line group, so stepping one changes the other's quantity and both line totals with it:

Give each line its own DataScope and they add up independently:

The scoped markup, one line:

<div data-component="DataScope" data-option-group="line">
  <button
    type="button"
    aria-label="Decrease quantity"
    data-component="Action"
    data-on:click="DataModel(#qty) -> target.increment(-1)">

  </button>
  <input
    id="qty"
    type="number"
    name="qty"
    value="1"
    aria-label="Quantity"
    data-component="DataModel"
    data-option-immediate />
  <button
    type="button"
    aria-label="Increase quantity"
    data-component="Action"
    data-on:click="DataModel(#qty) -> target.increment()">
    +
  </button>

  Line total:
  <span
    data-component="DataComputed"
    data-option-key="qty"
    data-option-compute="`€${(value * 25).toFixed(2)}`">
    €25.00
  </span>
</div>

Same story as the tabs: the scope is the whole difference. Wrap the line and swap the app-wide group for a scoped key (the <input> takes its key from its name):

-<div>
+<div data-component="DataScope" data-option-group="line">
   <!-- − / + buttons and the qty <input name="qty"> -->
   <span
     data-component="DataComputed"
-    data-option-group="line"
+    data-option-key="qty"
     data-option-compute="`€${(value * 25).toFixed(2)}`">…</span>
 </div>

When to skip DataScope

Not every pattern needs shared state. A dropdown or popover menu is better left to the native Popover API, which handles opening, top-layer stacking and click-outside dismissal on its own. Reach for DataScope when several elements must stay in sync on one value and you want two of them to coexist on a page.

Register once, use everywhere

Every demo above shares the same registration; DataScope only needs to wrap the markup.

import { import registerComponentsregisterComponents } from '@studiometa/js-toolkit';
import { class Action<T extends BaseProps = BaseProps>

Action class.

Action
, class DataBind<T extends BaseProps = BaseProps>

DataBind class.

DataBind
, class DataComputed<T extends BaseProps = BaseProps>DataComputed, class DataModel<T extends BaseProps = BaseProps>DataModel, import DataScopeDataScope } from '@studiometa/ui';
import registerComponentsregisterComponents(import DataScopeDataScope, class DataModel<T extends BaseProps = BaseProps>DataModel, class DataBind<T extends BaseProps = BaseProps>

DataBind class.

DataBind
, class DataComputed<T extends BaseProps = BaseProps>DataComputed, class Action<T extends BaseProps = BaseProps>

Action class.

Action
);