Reactivity & Signals
PraxisJS uses signals for fine-grained reactivity — only the specific DOM nodes that depend on a changed signal are updated.
Reactivity & Signals
PraxisJS uses signals as the core reactive primitive. A signal holds a value and notifies its subscribers when that value changes.
The reactive contract
render() runs once. Inside JSX, the rule is simple:
Arrow functions are reactive. Plain expressions are static.
render() {
return (
<div>
<p>{() => this.count}</p> {/* ✅ reactive — updates when count changes */}
<p>{this.count}</p> {/* ❌ static — value captured at render time */}
<p>{this.count * 2}</p> {/* ❌ static */}
<p>{() => this.count * 2}</p> {/* ✅ reactive */}
</div>
)
}Each {() => expr} becomes its own reactive effect. When any signal it reads changes, only that specific DOM node updates — nothing else in the component is touched.
@State() — reactive fields
@State() turns a class field into a signal. Reading it inside an arrow function subscribes to it. Writing to it triggers all subscribers.
@Component()
class Timer extends StatefulComponent {
@State() seconds = 0
onMount() {
setInterval(() => this.seconds++, 1000)
}
render() {
return <p>Elapsed: {() => this.seconds}s</p>
}
}this.seconds++ sets the signal, which notifies the {() => this.seconds} effect in the DOM, which updates the text node. That's the entire update cycle.
@Computed() — derived state
@Computed() creates a cached, derived reactive value. It recomputes only when its signal dependencies change — not on every read.
@Component()
class Cart extends StatefulComponent {
@State() items: { price: number }[] = []
@Computed()
get total() {
return this.items.reduce((sum, i) => sum + i.price, 0)
}
render() {
return <p>Total: {() => this.total}</p>
}
}Plain getter vs @Computed
A plain get total() recalculates every time it's read, including inside every reactive effect that reads it. @Computed() caches the result and recomputes only when its own signal dependencies change — making it safe and cheap to use in multiple places.
Coalesced updates
When multiple signals change in the same synchronous block, computed values update once with the final result — never with an intermediate state:
@Computed()
get fullName() { return `${this.first} ${this.last}` }
// Somewhere:
this.first = 'Jane'
this.last = 'Smith'
// → DOM updates once with "Jane Smith", never shows "Jane Doe"Arrays and objects
Signals track reference changes, not deep mutations. Always replace with a new reference:
// ✅ new reference — triggers update
this.items = [...this.items, newItem]
this.config = { ...this.config, theme: 'dark' }
// ❌ in-place mutation — does NOT trigger update
this.items.push(newItem)
this.config.theme = 'dark'Need deep mutation tracking? See @DeepState for a proxy-based alternative.
Passing reactive props to children
When you pass a value to a child component, the difference between static and reactive applies there too:
// ❌ static — Badge receives the value at render time and never updates
<Badge value={this.count} />
// ✅ reactive — Badge receives a getter; reads it when the parent's count changes
<Badge value={() => this.count} />The child component accesses the live value via this.props.value (if using StatelessComponent) or {() => this.value} (if using @Prop()).
Rule of thumb
Pass () => this.state when the child should stay in sync with the parent's changes. Pass this.state when you want to capture the value at render time and the child doesn't need to update.
Using @Prop() in the child
@Prop() unwraps the getter automatically — use {() => this.text} to create the reactive binding in the template:
@Component()
class Label extends StatefulComponent {
@Prop() text = ''
render() {
return <span>{() => this.text}</span> // reactive
}
}Using raw this.props (StatelessComponent)
this.props.text returns whatever the parent passed — if the parent passed a getter function, the renderer detects it and makes it reactive automatically:
@Component()
class Label extends StatelessComponent<{ text: string }> {
render() {
return <span>{this.props.text}</span> // works reactively when parent passes () => this.count
}
}Reading without tracking
Sometimes you need a signal's current value without creating a reactive subscription.
peek(signal) — read once
import { peek } from '@praxisjs/core'
increment() {
if (peek(this.count) < peek(this.max)) {
this.count++
}
}untrack(fn) — suppress tracking for a block
import { untrack } from '@praxisjs/core'
@Watch('items')
onItemsChange() {
const snapshot = untrack(() => this.totalCost)
console.log('items changed, cost at the time was:', snapshot)
}When to use each
Use peek(signal) when you have a direct signal reference and want a clean explicit read. Use untrack(fn) when you want to suppress tracking across an entire block of code that may read multiple signals.
What's next?
- JSX Syntax — reactive and static expressions in templates, conditionals, lists, and refs
- Decorators: State & Props — full reference for
@State,@Prop,@Computed,@Persisted,@DeepState