State & Props
Decorators for managing reactive state and external props — @State, @Prop, @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} />@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.
Compact form — @Computed({ get, set }) on accessor
Pass both get and set to the decorator factory and declare the field with the accessor keyword. TypeScript treats accessor fields as read-write, so no cast is needed.
@Computed({
get(this: TemperatureConverter): number {
return (this.celsius * 9) / 5 + 32
},
set(this: TemperatureConverter, fahrenheit: number) {
this.celsius = ((fahrenheit - 32) * 5) / 9
},
})
accessor fahrenheit!: numberThe ! (definite assignment assertion) tells TypeScript that the decorator initializes the field, so no initializer expression is needed. At runtime, reads go through a reactive computed backed by get; writes call set directly.
accessor vs getter + setter
Both forms are fully TypeScript-compatible. Use the accessor form when you prefer to keep the getter formula and setter logic co-located inside the decorator options. Use the plain getter + setter pair when you want the getter formula visible as a class method for readability or when you need to call super.
@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 |
@History(fieldName, limit?)
Adds undo/redo to a @State property. 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.
@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.