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