Skip to content

Performance

Whisq’s signal-based reactivity is efficient by default — only the DOM nodes that read a signal update when it changes. But in complex applications, a few patterns can make a significant difference.

Prefer many small signals over one big object:

// ❌ Less efficient — entire UI re-checks when any field changes
const user = signal({ name: "Alice", age: 30, email: "alice@example.com" });
// ✅ More efficient — only name UI updates when name changes
const name = signal("Alice");
const age = signal(30);
const email = signal("alice@example.com");

With fine-grained signals, changing name.value only updates DOM nodes that read name.value. With a single object signal, every node that reads any property re-evaluates.

When updating multiple signals at once, wrap them in batch():

import { signal, batch } from "@whisq/core";
const x = signal(0);
const y = signal(0);
const z = signal(0);
// ❌ Three separate UI updates
x.value = 1;
y.value = 2;
z.value = 3;
// ✅ One UI update
batch(() => {
x.value = 1;
y.value = 2;
z.value = 3;
});

batch() defers all reactive updates until the function completes, then flushes them once.

When an effect needs to read a signal without subscribing to it, use peek():

import { signal, effect } from "@whisq/core";
const count = signal(0);
const log = signal<string[]>([]);
// ❌ Infinite loop — effect reads log.value, which triggers re-run
effect(() => {
log.value = [...log.value, `Count changed to ${count.value}`];
});
// ✅ peek() reads without creating a dependency
effect(() => {
const current = log.peek();
log.value = [...current, `Count changed to ${count.value}`];
});

peek() reads the signal’s current value without tracking it as a dependency. The effect above re-runs when count changes but not when log changes.

computed() caches its result and only re-evaluates when dependencies change. But avoid creating computed values for trivial derivations:

// ❌ Overkill — computed overhead for a simple check
const isEmpty = computed(() => items.value.length === 0);
// ✅ Just inline it
when(() => items.value.length === 0, () => p("No items"));

Use computed() when:

  • The derivation is expensive (filtering, sorting, mapping large arrays)
  • Multiple parts of the UI read the same derived value
  • You want to name the value for readability

Split large apps by loading components on demand:

import { signal, component, div, when } from "@whisq/core";
const Dashboard = component(() => {
const loaded = signal<(() => Node) | null>(null);
// Load the heavy component on demand
const loadChart = async () => {
const { ChartWidget } = await import("./ChartWidget");
loaded.value = () => ChartWidget({});
};
return div(
button({ onclick: loadChart }, "Load Chart"),
when(() => !!loaded.value, () => loaded.value!()),
);
});

Use @whisq/devtools to inspect signal updates and identify performance bottlenecks:

import { installDevtools } from "@whisq/devtools";
// Enable in development
if (import.meta.env.DEV) {
installDevtools();
}

The devtools show:

  • Signal update frequency — which signals change most often
  • Effect execution count — which effects re-run frequently
  • Component tree — the current component hierarchy
  • Dependency graph — which signals each effect depends on
PatternImpactWhen to use
Fine-grained signalsHighAlways — prefer small signals
batch()MediumWhen updating 2+ signals together
peek()MediumWhen reading a signal in an effect without tracking
Lazy loadingHighFor large components not needed at startup
computed() cachingMediumFor expensive derivations read in multiple places
  • Signals — How reactivity works
  • Lifecycle — onMount, onCleanup, effect
  • Sandbox — Isolated execution for untrusted code