Signals

A signal is the fundamental reactive primitive in Elur. It holds a value and notifies dependents when that value changes.

# Creating a signal

import { signal } from "@elurjs/core";

const count = signal(0);
const name = signal("Alice");
const items = signal<number[]>([]);

signal(initialValue) returns a Signal object with a .value property.

# Reading and writing

// Read
console.log(count.value); // 0

// Write
count.value = 1;
count.value = count.value + 1;

// Shorthand
count.value++;

Reading a signal inside a tracking context (computed, effect, template interpolation) creates a dependency. Reading outside a tracking context just returns the current value.

# The .value getter/setter

The value property uses JavaScript getters and setters:

  • Getter: if inside a tracking context, registers the dependency. Returns the current value.
  • Setter: updates the value and notifies all dependents (batched within the current tick).

# Peeking without tracking

Use peek() to read a signal's value without creating a dependency:

const a = signal(1);
const b = signal(2);

const sum = computed(() => {
  // Only depends on `a`, not `b`
  return a.value + b.peek();
});

a.value = 10; // sum recalculates
b.value = 20; // sum does NOT recalculate

# Updating with a function

You can update a signal using a function of its current value:

count.update((n) => n + 1);

This is equivalent to count.value = count.value + 1 but reads and writes in a single call.

# dispose()

Clears all subscriber subscriptions on the signal. After disposing, no effect or computed will be notified of further changes:

const count = signal(0);
const stop = effect(() => console.log(count.value)); // 0

count.value = 1; // logs 1
count.dispose(); // subscribers cleared
count.value = 2; // no log — effect still runs but is no longer subscribed
💡 Tip

dispose() is mainly useful for long-lived signals that you want to tear down explicitly (e.g. in tests or when unmounting a component). Effects returned by effect() already clean up their own subscriptions when you call the returned dispose function.

# Equality check

Signals use Object.is to compare the old and new value. If they're equal, no notification is fired — dependents are not re-run:

const count = signal(0);
count.value = 0; // no notification — Object.is(0, 0) is true
count.value = 1; // notification — value changed

This means objects and arrays need immutable updates to trigger notifications. Replacing the value with a new reference works; mutating a property in place does not:

const user = signal({ name: "Alice" });

// ❌ No notification — same object reference, Object.is is true
user.value.name = "Bob";

// ✅ Notification — new object reference
user.value = { ...user.value, name: "Bob" };
ℹ Note

signal() only accepts the initial value — there is no options argument. For custom equality on derived values, use computed(fn, equals) where equals is the second positional argument. See Computed Values.

# Signals vs refs

Elur also has ref() for DOM element references. Don't confuse them:

  • signal() — a reactive value container.
  • ref() — a reference to a DOM element (not reactive by itself).

# Common patterns

# Toggle

const open = signal(false);
const toggle = () => open.value = !open.value;

# Form input

const text = signal("");

html`<input
  value=${() => text.value}
  @input=${(e: Event) => text.value = (e.target as HTMLInputElement).value}
/>`;

# Counter with bounds

const count = signal(0);

const increment = () => {
  count.value = Math.min(count.value + 1, 10);
};
ℹ Note

Signals are the foundation of all reactivity in Elur. Every reactive primitive (computed, effect, template interpolation) is built on top of signals.