Param Panel

Reactive Values

The reactive value model shared by every editable control — get, set, subscribe, reset, and the intermediate/final event distinction.

Every editable control in ParamPanel — sliders, numbers, checkboxes, text, selects, colors, and monitors — implements the same small reactive interface. Learn it once and it applies everywhere.

The value-control interface

interface ValueControl<T, C> extends Control<C> {
  get(): T;
  set(value: T, options?: SetValueOptions): this;
  subscribe(
    listener: ValueListener<T>,
    options?: SubscribeOptions,
  ): Unsubscribe;
  reset(options?: SetValueOptions): this;
}

This is built on a minimal reactive primitive — no external store library required:

interface Readable<T> {
  get(): T;
  subscribe(listener: ValueListener<T>): Unsubscribe;
}

interface Writable<T> extends Readable<T> {
  set(value: T, options?: SetValueOptions): this;
}

Value sources

Every value control's value option accepts more than a raw value — it accepts a ValueSource<T>, so you can either let the control own its value or bind it to state your app already owns:

interface GetterSetter<T> {
  get(): T;
  set(value: T): void;
}

type ValueSource<T> =
  | T // the control owns the value
  | GetterSetter<T> // read/write external state (call refresh() after outside changes)
  | ExternalValue<T>; // a reactive binding that stays in sync automatically

The config types below all write value: T for readability, but each accepts a ValueSource<T>. Regardless of the source, control.get() always returns T. See External state for the binding details.

Reading a value

get() is always synchronous, which makes it perfect for render loops:

const exposure = pp.slider({
  label: "Exposure",
  value: 1,
  min: 0,
  max: 4,
  step: 0.01,
});

function frame() {
  renderer.exposure = exposure.get();
  renderer.render(scene, camera);
  requestAnimationFrame(frame);
}

requestAnimationFrame(frame);

Writing a value

threshold.set(160);

By default set() marks the change as a completed interaction. It is equivalent to:

threshold.set(160, { final: true });

Pass final: false for an intermediate programmatic update — for example while animating a value:

threshold.set(140, { final: false });
interface SetValueOptions {
  /** Whether this update represents a completed interaction. Defaults to true. */
  final?: boolean;

  /** Apply the value without notifying subscribers. The DOM still updates. Defaults to false. */
  silent?: boolean;
}

Use silent: true to sync the UI to a value without re-triggering your own listeners — the same mechanism setState({ notify: false }) uses internally:

threshold.set(externalValue, { silent: true });

Subscribing to changes

subscribe() registers a listener and returns an Unsubscribe function.

type ValueListener<T> = (value: T, details: ChangeDetails<T>) => void;

interface ChangeDetails<T> {
  /** Previous value. Undefined only for the initial subscription callback. */
  previous: T | undefined;

  /** True when this is the completed interaction or operation. */
  final: boolean;

  /** True only for the immediate callback caused by subscribe(). */
  initial: boolean;
}
threshold.subscribe((value, details) => {
  previewThreshold(value);

  if (details.initial) return;

  if (details.final) {
    persistThreshold(value);
  }
});

Immediate callback

By default, subscribe() invokes the listener immediately with the current value so your app starts in sync:

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

threshold.subscribe((value, details) => {
  console.log(value, details.initial);
});

The first call receives:

value === 128;
details.initial === true;
details.previous === undefined;
details.final === true;

Disable it with immediate: false:

threshold.subscribe(listener, { immediate: false });
interface SubscribeOptions {
  /** Invoke the listener immediately with the current value. Defaults to true. */
  immediate?: boolean;
}

Intermediate vs. final events

This is the most important idea in the reactive model. Dragging a slider emits a stream of intermediate events followed by one final event:

{ value: 120, final: false }
{ value: 124, final: false }
{ value: 128, final: false }
{ value: 128, final: true }

The final event may repeat the most recent intermediate value — that is intentional. It gives you a single, reliable completion signal without a second event API. Run cheap live-preview work on every event, and expensive work only on final:

threshold.subscribe((value, details) => {
  updatePreview(value); // cheap, runs continuously

  if (details.final) {
    runExpensiveRebuild(value); // e.g. rebuild a lookup table
  }
});

Equality

Value changes are compared with Object.is, so setting a control to its current value normally does not notify subscribers:

control.set(currentValue); // no notification

The one exception: a final commit event may still be emitted after a run of intermediate changes, even when the final value equals the last intermediate one. That is what gives you the guaranteed completion signal above.

For object-valued controls (such as a select over objects), reuse stable references so Object.is behaves as you expect.

Resetting

reset() returns a control to the initial value supplied at construction:

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

threshold.set(200);
threshold.reset();

console.log(threshold.get()); // 128

The reset baseline

reset() restores a baseline, which defaults to the construction value. The rules are explicit:

  • Changing value through configure() does not move the baseline.
  • Setting resetValue does move the baseline, without changing the current value.
threshold.configure({ resetValue: 160 });
threshold.reset();

console.log(threshold.get()); // 160

This keeps reset predictable when a control's default legitimately changes at runtime — for example after loading a preset.

Shared types reference

Unsubscribe

type Unsubscribe = () => void;
  • Calling it removes the listener.
  • Calling it more than once is safe.
  • Calling it after the control is destroyed is safe.

On this page