Signals

Signals are the foundation of What's reactivity system. They hold values that can change over time and automatically track where they're used.

What is a Signal?

A signal is a container for a value that can change. When you read a signal inside a reactive context (like JSX or an effect), that context automatically "subscribes" to the signal. When the signal's value changes, all subscribers update.

import { signal } from 'what-framework';

function Greeting() {
  const name = signal('World');

  return (
    <div>
      <h1>Hello, {name()}!</h1>
      <button onClick={() => name.set('What')}>Change name</button>
    </div>
  );
}

Reading Signals

Call the signal like a function to read its value:

const name = signal('Alice');

// Regular read (creates subscription in reactive contexts)
console.log(name());  // "Alice"

// Read without subscribing
console.log(name.peek());  // "Alice" (no subscription)

In JSX, you can use signals directly or call them explicitly. The compiler detects reactive expressions (function calls) and auto-wraps them:

// All work in JSX:
<span>{name()}</span>    // compiler auto-wraps → () => name()
<span>{() => name()}</span>  // explicit reactive wrapper — also works

Compiler Auto-Wrapping

The What compiler detects expressions containing function calls (like count()) in JSX children and attributes, and automatically wraps them in reactive arrow functions. You don't need to write {() => count()} manually — just {count()} is enough. The manual form still works and is equivalent.

When to use peek()

Use peek() when you want to read a value without creating a subscription. This is useful in effects where you want to read a value once without re-running when it changes.

Updating Signals

Use .set() to update a signal's value. Always update signals inside event handlers, effects, or callbacks — never bare in the component body.

function Counter() {
  const count = signal(0);

  return (
    <div>
      <p>{count()}</p>
      // Set a new value directly
      <button onClick={() => count.set(10)}>Set to 10</button>
      // Update based on current value
      <button onClick={() => count.set(c => c + 1)}>+1</button>
      // Decrement
      <button onClick={() => count.set(c => c - 1)}>-1</button>
    </div>
  );
}

Never call .set() in the component body

Calling .set() directly in a component function (outside event handlers or effects) causes an infinite loop — the update triggers a re-render, which calls .set() again, forever.

// BAD — infinite loop!
function Broken() {
  const count = signal(0);
  count.set(1);  // Triggers re-render → set again → infinite
  return <p>{count()}</p>;
}

Use onMount if you need to set a value once after the component renders. Use effect if it should react to other signals.

Updating Objects and Arrays

For objects and arrays, create a new reference when updating:

const user = signal({ name: 'Alice', age: 25 });

// Update a property (spread to create new object)
user.set(u => ({ ...u, age: 26 }));

// Arrays work the same way
const items = signal([1, 2, 3]);

// Add item
items.set(arr => [...arr, 4]);

// Remove item
items.set(arr => arr.filter(x => x !== 2));

// Update item
items.set(arr => arr.map(x => x === 3 ? 30 : x));

Don't Mutate

Never mutate signal values directly. Always create new references so What can detect changes:

// BAD - mutation won't trigger updates
user().name = 'Bob';

// GOOD - creates new reference
user.set(u => ({ ...u, name: 'Bob' }));

Computed Values

A computed is a derived signal that automatically updates when its dependencies change:

import { signal, computed } from 'what-framework';

const firstName = signal('John');
const lastName = signal('Doe');

// Automatically tracks firstName and lastName
const fullName = computed(() =>
  `${firstName()} ${lastName()}`
);

console.log(fullName());  // "John Doe"

firstName.set('Jane');
console.log(fullName());  // "Jane Doe"

Caching and Laziness

Computed values are:

  • Lazy — They don't compute until first read
  • Cached — They only recompute when dependencies change
  • Efficient — Even if dependencies change multiple times, they only compute once per read
const count = signal(0);

const expensive = computed(() => {
  console.log('Computing...');
  return count() * 2;
});

// Nothing logged yet (lazy)

console.log(expensive());  // Logs "Computing...", returns 0
console.log(expensive());  // Returns 0 (cached, no log)

count.set(5);
console.log(expensive());  // Logs "Computing...", returns 10

Batching Updates

When you update multiple signals, you can batch them to prevent intermediate updates:

import { signal, batch, effect } from 'what-framework';

const firstName = signal('John');
const lastName = signal('Doe');

effect(() => {
  console.log(`Name: ${firstName()} ${lastName()}`);
});

// Without batch: logs twice
firstName.set('Jane');  // Logs "Name: Jane Doe"
lastName.set('Smith');  // Logs "Name: Jane Smith"

// With batch: logs once
batch(() => {
  firstName.set('Bob');
  lastName.set('Jones');
});  // Logs "Name: Bob Jones"

Reading Without Tracking

Use untrack() to read signals without creating subscriptions:

import { signal, effect, untrack } from 'what-framework';

const count = signal(0);
const multiplier = signal(2);

effect(() => {
  // Re-runs when count changes, but NOT when multiplier changes
  const result = count() * untrack(() => multiplier());
  console.log(result);
});

Best Practices

1. Keep Signals Focused

Create separate signals for unrelated data:

// GOOD - separate concerns
const name = signal('Alice');
const age = signal(25);

// AVOID - unrelated data bundled together
const state = signal({ name: 'Alice', age: 25, theme: 'dark' });

2. Prefer Computed Over Effects for Derived Values

// GOOD - computed for derived values
const fullName = computed(() => `${first()} ${last()}`);

// AVOID - effect with manual state
const fullName = signal('');
effect(() => {
  fullName.set(`${first()} ${last()}`);  // Don't do this
});

3. Use Functional Updates for Derived Values

// GOOD - uses current value
count.set(c => c + 1);

// RISKY - reads stale closure
count.set(count() + 1);  // May be stale in async code