Param Panel

External state integration

Bind controls to application-owned state — plain objects, reactive stores, or Nano Stores — without a synchronization loop.

ParamPanel controls own their value by default, but they can also bind to state your application already owns. There are two binding styles: getter/setter (pull) and reactive (push).

These are exactly the non-raw members of the ValueSource<T> union that every value control's value option accepts — a raw T, a GetterSetter<T>, or an ExternalValue<T>.

Getter/setter binding

Pass a { get, set } object as the control's value. The control reads and writes your state instead of holding its own:

const settings = {
  threshold: 128,
};

const threshold = pp.slider({
  label: "Threshold",
  value: {
    get: () => settings.threshold,
    set: (value) => {
      settings.threshold = value;
    },
  },
  min: 0,
  max: 255,
});

A plain getter/setter cannot tell ParamPanel when the underlying value changes from elsewhere. After mutating it externally, call refresh():

settings.threshold = 180;
threshold.refresh();

Structural reactive binding

If your state can notify subscribers, bind it structurally and skip the manual refresh entirely. Any value matching this shape works:

interface ExternalValue<T> {
  get(): T;
  set(value: T): void;
  subscribe(listener: (value: T) => void): Unsubscribe;
}
const thresholdStore = createStore(128);

const threshold = pp.slider({
  label: "Threshold",
  value: thresholdStore,
  min: 0,
  max: 255,
});

Now synchronization is automatic and two-way:

  • User changes the control → the store is updated.
  • The store changes → the control UI is updated.
  • No polling is required.
  • The binding is removed when the control is destroyed.

No synchronization loops

ParamPanel guards against the classic feedback loop — control writes store, store notifies control, control writes store again — using equality checks and internal update guards. As long as your store skips no-op notifications (compares with Object.is before emitting), updates settle in a single pass.

Nano Stores adapter

Nano Stores is supported through an optional adapter and is not a dependency of the core package:

import { atom } from "nanostores";
import { fromNanostore } from "param-panel/nanostores";

const $threshold = atom(128);

const threshold = pp.slider({
  label: "Threshold",
  value: fromNanostore($threshold),
  min: 0,
  max: 255,
});

Other integrations

The same ExternalValue<T> shape adapts cleanly to Svelte stores, Vue refs, Solid signals, React state, an RxJS BehaviorSubject, or a custom event emitter. Write a thin adapter that exposes get/set/subscribe — no framework dependency is ever added to core.

Reference: a minimal store

The createStore used above is just any object implementing the interface. Here is a complete, dependency-free one:

function createStore<T>(initial: T) {
  let value = initial;
  const listeners = new Set<(value: T) => void>();

  return {
    get() {
      return value;
    },

    set(next: T) {
      if (Object.is(value, next)) return; // skip no-op — prevents loops

      value = next;
      for (const listener of listeners) listener(value);
    },

    subscribe(listener: (value: T) => void) {
      listeners.add(listener);
      listener(value);
      return () => listeners.delete(listener);
    },
  };
}

Bound to a control, changes flow both directions:

const thresholdStore = createStore(128);

const threshold = pp.slider({
  label: "Threshold",
  value: thresholdStore,
  min: 0,
  max: 255,
});

thresholdStore.set(180);
console.log(threshold.get()); // 180

threshold.set(200);
console.log(thresholdStore.get()); // 200

On this page