Creating Decorators
How to build custom decorators in PraxisJS using createFieldDecorator, createMethodDecorator, createLifecycleMethodDecorator, createGetterDecorator, createWritableGetterDecorator, createGetterObserverDecorator, createAccessorDecorator, and createClassDecorator.
Creating Decorators
PraxisJS provides factory functions for creating decorators. Each handles a different use case and manages per-instance state correctly.
npm install @praxisjs/decorators @praxisjs/corepnpm add @praxisjs/decorators @praxisjs/coreyarn add @praxisjs/decorators @praxisjs/corebun add @praxisjs/decorators @praxisjs/core| Factory | Applied to | bind / wrap called | Use when |
|---|---|---|---|
createFieldDecorator | plain field | once per instance | replacing a field = value with custom reactive behavior |
createMethodDecorator | method | once per instance | wrapping a method with cross-cutting logic |
createLifecycleMethodDecorator | method | once per instance (in onMount) | auto-registering a method as an event listener |
createGetterDecorator | get accessor | on every read | transforming or constraining a getter's return value |
createWritableGetterDecorator | get accessor | once per instance | intercepting both reads and writes; setter owned by the decorator |
createGetterObserverDecorator | get accessor | once per instance | observing a getter for side effects without changing its value |
createAccessorDecorator | accessor field | once per instance (lazy) | owning the full state of a writable reactive field |
createClassDecorator | class | once per instance | augmenting lifecycle hooks or wrapping render() |
Internal APIs
Decorator implementations have access to the reactive engine through @praxisjs/core/internal — signal, computed, effect, batch, peek, and more. See the Internal APIs reference for the full list.
Field decorators
Use createFieldDecorator to replace a property with custom reactive behavior.
bind() is called once per instance when the class initializes. Return a descriptor to replace the property with a custom getter/setter.
Example — @SessionValue stores a value in sessionStorage and stays reactive:
import { createFieldDecorator } from '@praxisjs/decorators'
import { signal } from '@praxisjs/core/internal'
export function SessionValue(key: string) {
return createFieldDecorator({
bind(_instance, name) {
const stored = sessionStorage.getItem(key)
const _value = signal(stored ?? '')
return {
descriptor: {
get() { return _value() },
set(v: string) {
_value.set(v)
sessionStorage.setItem(key, v)
},
},
}
},
})
}@Component()
class SearchPage extends StatefulComponent {
@SessionValue('search:query')
query = ''
render() {
return (
<input
value={() => this.query}
onInput={(e) => { this.query = (e.target as HTMLInputElement).value }}
/>
)
}
}bind() receives (instance, name, initialValue). The FieldBinding you return can also include onMount, onUnmount, and additional (extra properties to define on the instance).
Method decorators
Use createMethodDecorator to wrap a method with cross-cutting behavior.
wrap() receives the original function and returns a replacement. It's called once per instance.
Example — @Confirm shows a dialog before running:
import { createMethodDecorator } from '@praxisjs/decorators'
export function Confirm(message: string) {
return createMethodDecorator({
wrap(original) {
return function (this: object, ...args: unknown[]) {
if (window.confirm(message)) {
return original.apply(this, args)
}
}
},
})
}@Confirm('Delete this item permanently?')
async deleteItem(id: number) {
await api.delete(id)
}For per-instance state, use a WeakMap keyed on the instance:
export function CountCalls() {
const counts = new WeakMap<object, number>()
return createMethodDecorator({
wrap(original, instance) {
counts.set(instance, 0)
return function (this: object, ...args: unknown[]) {
counts.set(this, (counts.get(this) ?? 0) + 1)
return original.apply(this, args)
}
},
})
}Lifecycle method decorators
Use createLifecycleMethodDecorator to automatically register a method as a listener on mount and clean it up on unmount — without the component knowing about it.
register(callback, instance) is called inside onMount. If it returns a function, that function is called on onUnmount as cleanup.
Example — @OnResize calls a method whenever the window is resized:
import { createLifecycleMethodDecorator } from '@praxisjs/decorators'
export function OnResize() {
return createLifecycleMethodDecorator({
register(callback) {
window.addEventListener('resize', callback)
return () => window.removeEventListener('resize', callback)
},
})
}@Component()
class Layout extends StatefulComponent {
@State() cols = 3
@OnResize()
recalculate() {
this.cols = window.innerWidth > 1024 ? 4 : window.innerWidth > 640 ? 3 : 1
}
render() {
return <Grid cols={() => this.cols} />
}
}No manual addEventListener/removeEventListener needed — the decorator handles it.
Getter decorators
createGetterDecorator — transform or constrain a getter's value
wrap(original, instance) is called on every property access and should return a function that computes the final value.
Example — @Clamp(min, max) constrains a getter to a numeric range:
import { createGetterDecorator } from '@praxisjs/decorators'
export function Clamp(min: number, max: number) {
return createGetterDecorator({
wrap(original, instance) {
return () => {
const value = original.call(instance) as number
return Math.min(max, Math.max(min, value))
}
},
})
}@Component()
class Slider extends StatefulComponent {
@State() raw = 0
@Clamp(0, 100)
get value() { return this.raw }
render() {
return <p>{() => this.value}</p> // always between 0 and 100
}
}createWritableGetterDecorator — getter with a decorator-owned setter
bind(instance, name, original) is called once per instance. original is the getter formula from the class body. Return { get, set? } — both are installed on the instance via Object.defineProperty.
Use this when the decorator needs to intercept writes as well as reads, and the getter formula stays visible in the class body.
Example — @Overrideable lets any computed getter be pinned to a static value by assigning to it, while falling back to the derived value when cleared:
import { createWritableGetterDecorator } from '@praxisjs/decorators'
import { signal } from '@praxisjs/core/internal'
export function Overrideable<T>() {
return createWritableGetterDecorator<T>({
bind(_instance, _name, original) {
const override = signal<T | undefined>(undefined)
return {
get: () => override() !== undefined ? override() : original.call(_instance) as T,
set: (value) => override.set(value as T | undefined),
}
},
})
}@Component()
class PageHeader extends StatefulComponent {
@State() page = { title: 'Home' }
@Overrideable<string>()
get title() { return this.page.title } // derived by default
render() {
return (
<div>
<h1>{() => this.title}</h1>
<button onClick={() => { this.title = 'Custom' }}>Pin title</button>
<button onClick={() => { this.title = undefined }}>Reset</button>
</div>
)
}
}When this.title = 'Custom' is assigned, the override signal is set and the getter returns 'Custom'. Assigning undefined clears the override so the getter falls back to this.page.title.
Setter optionality
If set is omitted from the returned binding, the property remains read-only at runtime. TypeScript may still see a setter if one is declared in the class body — the decorator does not interfere with it.
Accessor decorators
Use createAccessorDecorator to replace an accessor field with fully custom reactive behavior. Unlike getter decorators, the field has no formula — both get and set are owned entirely by the decorator.
bind(instance, name, initialValue) is called once per instance on first access. initialValue is the value from the accessor foo = value initializer.
Example — @Bounded(min, max) creates a reactive numeric field that clamps every read and write:
import { createAccessorDecorator } from '@praxisjs/decorators'
import { signal } from '@praxisjs/core/internal'
export function Bounded(min: number, max: number) {
return createAccessorDecorator({
bind(_instance, _name, initialValue) {
const clamp = (v: number) => Math.min(max, Math.max(min, v))
const s = signal(clamp((initialValue as number) ?? min))
return {
get: () => clamp(s()),
set: (value) => s.set(clamp(value as number)),
}
},
})
}@Component()
class VideoPlayer extends StatefulComponent {
@Bounded(0, 100)
accessor volume = 80 // initialised to 80, clamped to [0, 100]
render() {
return (
<input
type="range" min={0} max={100}
value={() => this.volume}
onInput={(e) => { this.volume = Number((e.target as HTMLInputElement).value) }}
/>
)
}
}Setting this.volume = 150 stores 100; setting this.volume = -5 stores 0. The accessor keyword tells TypeScript the field is read-write.
accessor vs field decorator
Use createAccessorDecorator when the decorator owns the full state of the property (no external signals). Use createFieldDecorator when the property wraps a plain class field that may be assigned at class instantiation.
createGetterObserverDecorator — observe a getter for side effects
Observes a getter without changing its return value. observe(getter, instance, name) is called once per instance at initialization — set up a reactive effect here.
Example — @LogChange logs whenever a computed getter produces a new value:
import { createGetterObserverDecorator } from '@praxisjs/decorators'
import { effect } from '@praxisjs/core/internal'
export function LogChange() {
return createGetterObserverDecorator({
observe(getter, instance, name) {
effect(() => {
console.log(`[${name}]`, getter.call(instance))
})
},
})
}@LogChange()
@Computed()
get filteredItems() {
return this.items.filter(i => i.active)
}The observer runs once on setup, then again whenever a signal read inside getter changes.
Class decorators
Use createClassDecorator to augment the component lifecycle or wrap render().
Extend ClassBehavior and implement create(), called once per instance. Return a ClassEnhancement with onMount, onUnmount, and/or render.
Example — @Analytics tracks mount/unmount events automatically:
import { createClassDecorator, ClassBehavior } from '@praxisjs/decorators'
class AnalyticsBehavior extends ClassBehavior {
constructor(private readonly name: string) { super() }
create() {
const name = this.name
return {
onMount() { analytics.track('mount', { component: name }) },
onUnmount() { analytics.track('unmount', { component: name }) },
}
}
}
export function Analytics(name: string) {
return createClassDecorator(new AnalyticsBehavior(name))
}@Analytics('UserDashboard')
@Component()
class UserDashboard extends StatefulComponent { /* ... */ }To wrap rendering, return a render function that receives originalRender and can modify or wrap its output:
create(_instance) {
return {
render(originalRender) {
const nodes = originalRender()
// wrap with an error boundary, portal, loading gate, etc.
return nodes
},
}
}Internal API reference
Decorator implementations commonly use these internal primitives:
| Import | From | Description |
|---|---|---|
signal | @praxisjs/core/internal | Create reactive state inside a decorator binding. |
computed | @praxisjs/core/internal | Derive a memoized value from signals. |
effect | @praxisjs/core/internal | Run side effects that re-run when signals change. |
batch | @praxisjs/core/internal | Group multiple signal writes into a single notification. |
peek | @praxisjs/core/internal | Read a signal without subscribing. |
RootComponent | @praxisjs/core/internal | Type the instance parameter in class decorators. |
isSignal | @praxisjs/shared/internal | Check if a value is a signal at runtime. |
ComponentConstructor | @praxisjs/shared/internal | Type a component class accepted as a parameter. |
Full reference → @praxisjs/core/internal · @praxisjs/shared/internal