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. When the compiler can see that the expression reads a signal, it auto-wraps it:

// Both stay live when the compiler recognizes `name` as a signal:
<span>{name()}</span>    // compiler auto-wraps → () => name()
<span>{() => name()}</span>  // explicit reactive wrapper, always works

Auto-wrapping needs the What compiler

The compiler wraps a JSX expression in a reactive arrow function when it can identify a signal read inside it: a signal() or computed() binding in scope, a destructured prop, or an accessor imported from another module. In those cases {count()} and {() => count()} compile to the same thing.

It cannot always tell. {props.count()} and a value read off a store are emitted as written, evaluated once, and never update. And with no compiler at all (the plain what-framework/jsx-runtime transform, or hand-written h(), which is what the fullstack scaffold uses) nothing is ever wrapped. The explicit {() => count()} form is correct on every path, so reach for it whenever you are not sure.

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. Update signals from event handlers, effects, or callbacks: a bare write in the component body runs once and never again, so it cannot drive the UI.

count.set(v) and count(v) are the same function. This guide writes .set() throughout because it reads more explicitly next to a read.

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>
  );
}

Comments inside JSX

A bare // line between JSX elements is not a comment, it is text, and it renders onto the page. Use {/* ... */} as above, or put the comment above the return.

Don't set a signal in the component body

Components run exactly once, so this is not the infinite loop it would be in React: there is no re-render to trigger. It is simply a write that happens once, before anything has subscribed, which makes it a slower way of writing signal(1).

// Pointless, not fatal: runs once, then never again
function Redundant() {
  const count = signal(0);
  count.set(1);  // same as signal(1)
  return <p>{count()}</p>;
}

Use onMount if you need to set a value once after the component is in the DOM. Use effect if it should react to other signals. The write that genuinely loops is one inside an effect that also reads the same signal, and What detects that and warns.

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

Effects are usually coalesced already. Writes made in the same tick queue their subscribers and flush together on the next microtask, so two writes normally produce one effect run without any help from you. Normally, not always: What promotes some single-dependency effects to an inline path, and those re-run synchronously on every write. batch() makes it definite. Every write inside the block feeds a single flush, and that flush happens before the block returns, so the DOM is up to date on the very next line.

The callback has to be synchronous: batch() does not await it, so an async callback leaves the batch at its first await and every write after that point runs unbatched.

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

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

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

// Without batch: both writes land in one flush
firstName.set('Jane');
lastName.set('Smith');
// Nothing logged yet: both writes are queued for the same flush

// With batch: the effect has already run when batch() returns
batch(() => {
  firstName.set('Bob');
  lastName.set('Jones');
});  // Logged "Name: Bob Jones"

If you need the pending effects drained right now but the writes are not yours to wrap, call flushSync() instead. It is the usual tool in tests, where you want to assert on the DOM immediately after a write.

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 - reads the current value without subscribing
count.set(c => c + 1);

// RISKY - the read subscribes whatever context you are in
count.set(count() + 1);

Both forms see the same value, but count() also subscribes the surrounding reactive context. Inside an effect that makes the effect depend on the signal it writes, so it re-triggers itself until What's loop guard stops it and warns. The updater form reads the current value directly and creates no subscription.