Reactivity
Stewie's reactivity model is fine-grained: each reactive value knows exactly which parts of the UI depend on it and updates only those parts. There is no virtual DOM, no component re-render cycle, and no reconciliation pass.
Signals
A signal is the fundamental reactive primitive — a value that notifies subscribers when it changes.
const count = signal(0)
count() // read — returns 0
count.set(5) // write — notifies subscribers
count.update(n => n + 1) // functional update
count.peek() // read without subscribingSignals can hold any value: primitives, objects, arrays, or null.
The key rule: signals must be created inside a component or reactiveScope(), not at module scope. In dev mode, creating a signal at module scope logs a warning.
Computed values
computed derives a value from other reactive values. It is lazy (only evaluates when read) and memoized (only notifies downstream when the result actually changes).
const count = signal(3)
const doubled = computed(() => count() * 2)
doubled() // 6
count.set(4)
doubled() // 8 — recomputed because count changedA computed value tracks its dependencies automatically. When count changes, doubled recomputes. If the new result is the same value (strict equality), it does not notify its own subscribers — the update stops there.
Computed values can depend on other computed values:
const total = computed(() => price() * quantity())
const withTax = computed(() => total() * 1.2)Effects
effect runs a function immediately and re-runs it whenever any reactive value it read changes.
const dispose = effect(() => {
console.log('count is now', count())
})Effects are for side effects: updating the DOM manually, syncing to localStorage, starting timers, etc. The Stewie renderer uses effects internally for reactive JSX — you rarely need to write effects in component code.
Cleanup: if fn returns a function, it is called before the next run and when the effect is disposed:
effect(() => {
const id = setInterval(() => tick.update(n => n + 1), 1000)
return () => clearInterval(id)
})Disposing: the return value of effect() is a dispose function:
const stop = effect(() => { ... })
stop() // unsubscribes, runs cleanupbatch
When multiple signals change together, batch defers all notifications until the callback completes. This prevents intermediate states from triggering effects.
batch(() => {
firstName.set('Jane')
lastName.set('Smith')
})
// effects run once, seeing both changesuntrack
untrack reads reactive values without registering a subscription in the current tracking scope.
effect(() => {
// subscribes to 'a', but not 'b'
const result = a() + untrack(() => b())
doSomethingWith(result)
})Store
store wraps an object (or array of objects) in a reactive proxy. It is the right tool when you have structured state with multiple interconnected properties.
const state = store({
user: { name: 'Alice', role: 'admin' },
todos: [] as { id: number; text: string; done: boolean }[]
})Reading a property inside a reactive context registers a subscription to that exact path:
effect(() => {
// only re-runs if state.user.name changes
// not triggered by state.user.role or state.todos
console.log(state.user.name)
})Writing a property notifies only subscribers of that path:
state.user.name = 'Bob' // notifies user.name subscribers only
state.todos.push({ ... }) // notifies todos subscribersArray mutation methods (push, pop, splice, sort, etc.) are all intercepted and trigger the appropriate notifications.
Store vs signal
Use signal for a single value that changes as a unit:
const isOpen = signal(false)
const selectedId = signal<string | null>(null)Use store for structured objects where you want path-level subscriptions — components that read only state.user.name should not update when state.todos changes:
const app = store({
user: { name: '', role: '' },
todos: []
})Subscription model
When a reactive expression (JSX attribute, computed function, or effect body) runs, Stewie pushes a tracking scope onto a stack. Every signal or store property read during execution registers itself with that scope. When the value changes, it notifies the scope, which re-runs the expression.
This means subscriptions are:
- automatic — you don't declare dependencies
- exact — only the values actually read during a run are tracked
- dynamic — if a conditional branch means a value isn't read, it isn't tracked
const showExtra = signal(false)
const extra = signal('hello')
effect(() => {
if (showExtra()) {
// extra is only subscribed when showExtra() is true
console.log(extra())
}
})Further reading
signal,computed,effect,batch,untrack— full API referencestore— store API reference- Derived Collections from Store State — a common gotcha when filtering store arrays
