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.
@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 read-only 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>
}
}@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.
@Component()
class PostList extends StatefulComponent {
@State() page = 1
@Resource(() => fetch(`/api/posts?page=${this.page}`).then(r => r.json()))
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>
)
}
}| 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.
@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.