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 automaticallyThe 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 notificationThe 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()); // 128The reset baseline
reset() restores a baseline, which defaults to the construction value. The
rules are explicit:
- Changing
valuethroughconfigure()does not move the baseline. - Setting
resetValuedoes move the baseline, without changing the current value.
threshold.configure({ resetValue: 160 });
threshold.reset();
console.log(threshold.get()); // 160This 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.