State & Props
Decorators for managing reactive state and external props — @State, @Prop, @FunctionProp, @Computed, @Persisted, @History, @Resource, @Synced, and @DeepState.
State & Props
Decorators for declaring reactive state and receiving external props. All imported from @praxisjs/decorators.
npm install @praxisjs/decoratorspnpm add @praxisjs/decoratorsyarn add @praxisjs/decoratorsbun add @praxisjs/decorators@State()
Turns a class field into a reactive signal. Reading it inside an arrow function in JSX subscribes to future updates. Writing to it notifies all subscribers.
@Component()
class Toggle extends StatefulComponent {
@State() open = false
render() {
return (
<button onClick={() => { this.open = !this.open }}>
{() => this.open ? 'Close' : 'Open'}
</button>
)
}
}Arrays and objects need new references
@State tracks reference equality. Mutate arrays and objects by replacing them:
// ✅ triggers update
this.items = [...this.items, newItem]
this.config = { ...this.config, theme: 'dark' }
// ❌ no update — same reference
this.items.push(newItem)
this.config.theme = 'dark'@Prop()
Declares an external prop received from the parent. The initialized value is the default when the parent doesn't pass one.
@Component()
class Button extends StatefulComponent {
@Prop() label = 'Click me'
@Prop() disabled = false
render() {
return (
<button disabled={() => this.disabled}>
{() => this.label}
</button>
)
}
}
// Usage:
<Button label="Submit" disabled={false} />@FunctionProp()
Declares an external prop whose value is a function. Use it when the parent passes a callback, predicate, formatter, or filtering function and the component should receive that function itself.
@Prop() treats a function prop as a reactive getter and calls it when the field is read. @FunctionProp() keeps the function intact.
Non-function values
If the parent passes a non-function value, @FunctionProp() still returns it as-is but logs a console.warn in development (skipped when NODE_ENV === "production").
type Product = { name: string; price: number }
type ProductFilter = (product: Product) => boolean
@Component()
class ProductList extends StatefulComponent {
@Prop() items: Product[] = []
@FunctionProp() filter: ProductFilter = () => true
render() {
return (
<ul>
{() => this.items
.filter((item) => this.filter(item))
.map((item) => <li>{item.name}</li>)}
</ul>
)
}
}
// Usage:
<ProductList
items={products}
filter={(product) => product.price < 100}
/>@Computed()
A derived getter cached as a reactive computed(). Recomputes only when its signal dependencies change. When multiple dependencies change in the same synchronous block, subscribers are notified once with the final value.
@Component()
class Cart extends StatefulComponent {
@State() items: { name: string; price: number }[] = []
@Computed()
get total() {
return this.items.reduce((sum, i) => sum + i.price, 0)
}
render() {
return <p>Total: ${() => this.total}</p>
}
}Writable computed — getter + setter pair
Add a plain set accessor alongside the decorated getter. TypeScript sees the property as read-write, and @Computed() handles only the reactive caching on the getter side.
@Component()
class TemperatureConverter extends StatefulComponent {
@State() celsius = 0
@Computed()
get fahrenheit(): number {
return (this.celsius * 9) / 5 + 32
}
set fahrenheit(value: number) {
this.celsius = ((value - 32) * 5) / 9
}
render() {
return (
<div>
<input
type="number"
value={() => this.celsius}
onInput={(e: Event) => { this.celsius = Number((e.target as HTMLInputElement).value) }}
/>
<input
type="number"
value={() => this.fahrenheit}
onInput={(e: Event) => { this.fahrenheit = Number((e.target as HTMLInputElement).value) }}
/>
</div>
)
}
}The setter writes back to celsius, which reactively invalidates the cached getter — so fahrenheit recomputes on the next read. TypeScript is fully aware of the setter, so assigning to this.fahrenheit type-checks correctly.
One source of truth
The setter does not store its own value. It updates the signals that the getter depends on, keeping the derived value always consistent.
@Computed() stays on the class, so a subclass can override the getter and read the cached value through super.fahrenheit.
@Persisted(key?, options?)
Like @State, but persisted to localStorage and restored on page load. key defaults to the property name.
@Component()
class Settings extends StatefulComponent {
@Persisted() theme = 'light'
@Persisted('app:fontSize') fontSize = 14
render() {
return (
<select
value={() => this.theme}
onChange={(e) => { this.theme = (e.target as HTMLSelectElement).value }}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
)
}
}Setting the value to null or undefined removes the entry from localStorage.
| Option | Type | Default | Description |
|---|---|---|---|
serialize | (v: T) => string | JSON.stringify | Custom serializer |
deserialize | (s: string) => T | JSON.parse | Custom deserializer |
syncTabs | boolean | true | Sync across browser tabs via storage event |
writeDelay | number | 0 | Milliseconds to hold back localStorage writes. Writes inside the window are coalesced into one with the latest value; pending data is flushed on pagehide and on unmount. 0 writes on every assignment |
Set writeDelay on fields bound to fast input (a text box, a slider) so each keystroke doesn't serialize and write synchronously.
With syncTabs on, the storage listener is removed when the component unmounts, so mounting and unmounting a component repeatedly does not accumulate listeners.
@History(fieldName, limit?)
Adds undo/redo to a @State property. Tracking stops when the component unmounts. Declare a separate field decorated with @History('fieldName') and type it with HistoryOf<Class, 'fieldName'> for full intellisense.
import { History, HistoryOf } from '@praxisjs/decorators'
@Component()
class Editor extends StatefulComponent {
@State()
text = ''
@History('text', 100)
textHistory!: HistoryOf<Editor, 'text'>
render() {
return (
<div>
<textarea
value={() => this.text}
onInput={(e) => { this.text = (e.target as HTMLTextAreaElement).value }}
/>
<button
onClick={() => this.textHistory.undo()}
disabled={() => !this.textHistory.canUndo()}
>
Undo
</button>
<button
onClick={() => this.textHistory.redo()}
disabled={() => !this.textHistory.canRedo()}
>
Redo
</button>
</div>
)
}
}The HistoryOf type exposes: .undo(), .redo(), .canUndo(), .canRedo(). limit sets the maximum number of history entries (default: unlimited).
@Resource(fetcher, options?)
Binds an async resource to the field. Automatically re-fetches when any signal read inside the fetcher changes.
Pass self as the first parameter to receive the component instance — signal reads on self become reactive dependencies:
@Component()
class PostList extends StatefulComponent {
@State() page = 1
@Resource((self: PostList) =>
fetch(`/api/posts?page=${self.page}`).then(r => r.json() as Promise<Post[]>)
)
posts!: ResourceInstance<Post[]>
render() {
return (
<div>
{() => this.posts.pending() && <Spinner />}
{() => this.posts.error() && <p>Failed to load posts.</p>}
{() => this.posts.data()?.map(p => <PostCard post={p} />)}
<button onClick={() => this.page++}>Next page</button>
</div>
)
}
}For fetchers with no component dependency, the zero-argument form works without self:
@Resource(() => fetch('/api/config').then(r => r.json() as Promise<Config>))
config!: ResourceInstance<Config>| Option | Default | Description |
|---|---|---|
immediate | true | Fetch on initialization |
initialData | null | Value of .data() before the first fetch completes |
keepPreviousData | false | Keep old data visible while refetching |
The field exposes: .data(), .pending(), .error(), .status(), .refetch(), .cancel(), .mutate(value).
→ See Async Data for the complete guide.
@Synced(channelName?)
Like @State, but the value is synced in real-time across all open browser tabs via BroadcastChannel. channelName defaults to the field name.
@Component()
class CartButton extends StatefulComponent {
@Synced('cart') items: Product[] = []
render() {
return (
<button onClick={() => { this.items = [...this.items, newItem] }}>
Cart ({() => this.items.length})
</button>
)
}
}When any tab writes to this.items, all other open tabs update automatically. Combine with @Persisted for both persistence and live sync:
@Synced('cart')
@Persisted('cart')
items: Product[] = []Serialization uses JSON.stringify/JSON.parse. Values that aren't JSON-serializable (functions, class instances, undefined) aren't supported.
@Ref<T>()
Creates a DOM element ref. The decorated field becomes a Ref<T> object — a callable function that you pass directly to the JSX ref prop, with a .current property holding the element after mount.
import { Component, Ref } from '@praxisjs/decorators'
@Component()
class ResizeWatcher extends StatefulComponent {
@Ref<HTMLDivElement>()
containerRef!: Ref<HTMLDivElement>
render() {
return (
<div ref={this.containerRef}>
{/* this.containerRef.current holds the element after mount */}
</div>
)
}
}Pass the field name as a string to @Compose — the composable receives the ref object and reads .current on mount:
import { Component, Compose, Ref } from '@praxisjs/decorators'
import { ElementSize } from '@praxisjs/composables'
@Component()
class Card extends StatefulComponent {
@Ref<HTMLDivElement>()
cardRef!: Ref<HTMLDivElement>
@Compose(ElementSize, 'cardRef')
size!: ElementSize
render() {
return (
<div ref={this.cardRef}>
Width: {() => this.size.width}px
</div>
)
}
}Type: Ref<T> is ((el: T | null) => void) & { current: T | null } — it is both the callback for ref={} and holds the element in .current. Import Ref as both the decorator and the type annotation from a single import.
For refs outside of a class (module-level constants, options passed to @Lazy), use createRef<T>():
import { createRef } from '@praxisjs/decorators'
const scrollRoot = createRef<HTMLDivElement>()
const LazyWidget = Lazy({ root: scrollRoot })(Widget, {} as ClassDecoratorContext)
// In render:
<div ref={scrollRoot}>...</div>@DeepState()
Like @State, but uses a deep Proxy so nested mutations are reactive without needing to create new references.
@Component()
class ThemeEditor extends StatefulComponent {
@DeepState() config = { theme: { mode: 'light', accent: '#0070f3' }, fontSize: 14 }
render() {
return (
<div>
<p>Mode: {() => this.config.theme.mode}</p>
<button onClick={() => { this.config.theme.mode = 'dark' }}>Dark</button>
<button onClick={() => { this.config.fontSize++ }}>Larger text</button>
</div>
)
}
}Any mutation at any depth — including push, index assignment, and property deletion — triggers reactive updates. Assigning a value a property already holds, or deleting a key that doesn't exist, does not. Reading a nested object returns the same reference every time (this.config.theme === this.config.theme), so identity comparisons behave as you'd expect.
@State vs @DeepState
Prefer @State with immutable patterns (this.items = [...this.items, x]) — it's explicit and fine-grained. Use @DeepState when working with deeply nested structures where immutable patterns become too verbose.
@DeepState is coarse-grained: any nested mutation re-runs all effects that read the field, regardless of which property changed.
Limitations
Map, Set, and class instances are not tracked deeply — only plain objects and arrays.