Creating Decorators
Build your own decorators with createFieldDecorator, createMethodDecorator, createLifecycleMethodDecorator, createGetterDecorator, createWritableGetterDecorator, createGetterObserverDecorator and createClassDecorator. Covers the shared setup form, typing, performance, testing and migrating from createAccessorDecorator.
Creating Decorators
@praxisjs/decorators exports the builders every built-in decorator is made with. A decorator you build with them stacks with the built-ins, works on stores wrapped in plugin Proxies, survives SSR hydration and HMR, and keeps instances in V8's fast properties.
npm install @praxisjs/decorators @praxisjs/corepnpm add @praxisjs/decorators @praxisjs/coreyarn add @praxisjs/decorators @praxisjs/corebun add @praxisjs/decorators @praxisjs/coreWhich builder?
| I want to… | Use | Example |
|---|---|---|
| Give a field custom reactive get/set behavior | createFieldDecorator | @SessionValue('key') query = '' |
| Wrap a field another decorator already set up | createFieldDecorator with bind | @Trimmed() @State() email = '' |
| Wrap a method (confirm, log, debounce, guard) | createMethodDecorator | @Confirm('Sure?') remove() |
| Register a method somewhere on mount and undo it on unmount | createLifecycleMethodDecorator | @OnResize() recalculate() |
| Change what a getter returns | createGetterDecorator | @Clamp(0, 100) get value() |
| Make a getter writable, with the decorator handling writes | createWritableGetterDecorator | @Overridable() get title() |
| React to a getter's value without changing it | createGetterObserverDecorator | @LogChange() get total() |
Add mount/unmount behavior or wrap render() for a whole class | createClassDecorator | @Analytics('Dashboard') |
| Reuse reactive logic as an object with its own state | a composable | @Compose(NetworkStatus) |
Two forms: setup and bind
Most builders take one of two behaviors:
setup: runs once per decorated member, when the class is defined. What it returns (accessors, a replacement method or getter, hooks) is shared by every instance, andthisis the instance. Per-instance state lives in symbol-keyed slots on the instance. This is the recommended form.bind(wrapfor methods): runs once per instance, while it is built, and returns closures for that instance. Use it when the decorator needs something only an instance has, such as the accessor a decorator stacked under it installed.
The two forms are interchangeable from the outside: same decorator syntax, same stacking rules. They differ in what building an instance costs (see Performance).
Every builder also takes an optional label, used in error messages:
@Clamp() decorates getters only, not the method "reset".
@SessionValue(): setup() must return an object (a FieldSetup), got undefined.Internal APIs
Decorator implementations use the reactive engine through @praxisjs/core/internal: signal, computed, effect, batch, peek and more. See Internal API reference below.
Field decorators
setup: one accessor pair for every instance
setup(name, context) returns a FieldSetup. Its get and set replace the field on every instance, and init creates the instance's state from the field's initial value.
import { createFieldDecorator } from '@praxisjs/decorators'
import { signal } from '@praxisjs/core/internal'
import type { Signal } from '@praxisjs/shared'
type Slots = Record<symbol, unknown>
export function SessionValue(key: string) {
return createFieldDecorator<object, string>({
label: '@SessionValue()',
setup(name) {
const slot = Symbol(`sessionValue.${name}`)
return {
get(this: Slots) {
return (this[slot] as Signal<string>)()
},
set(this: Slots, value) {
(this[slot] as Signal<string>).set(value)
sessionStorage.setItem(key, value)
},
init(instance, initialValue) {
(instance as Slots)[slot] = signal(sessionStorage.getItem(key) ?? initialValue)
},
}
},
})
}@Component()
class SearchPage extends StatefulComponent {
@SessionValue('search:query') query = ''
render() {
return (
<input
value={() => this.query}
onInput={(e: Event) => { this.query = (e.target as HTMLInputElement).value }}
/>
)
}
}FieldSetup key | Runs | Notes |
|---|---|---|
get() | on every read | this is the instance. Leave out both get and set to keep the plain field. |
set(value) | on every write | Without it, a write throws a TypeError, as for any getter without a setter. |
init(instance, initialValue) | once per instance, before get/set are installed | Create every slot here, so all instances have the same shape. A decorator stacked under this one has already installed its property. |
onMount(instance) / onUnmount(instance) | on every mount / unmount | Chained after the instance's own hooks. |
enumerable | Defaults to true, like a plain field. |
Read the initial value from init, not from instance[name]: with @praxisjs/vite-plugin the innermost decorator defines the field itself, so it does not exist yet when init runs.
bind: one binding per instance
bind(instance, name, initialValue) returns a FieldBinding for that instance: a descriptor to install, additional properties, and onMount / onUnmount. It suits decorators that wrap what a decorator stacked under them installed, since that accessor only exists on the instance:
export function Trimmed() {
return createFieldDecorator<object, string>({
label: '@Trimmed()',
bind(instance, name) {
const inner = Object.getOwnPropertyDescriptor(instance, name)
return {
descriptor: {
get: () => inner?.get?.call(instance) as string,
set: (value: string) => inner?.set?.call(instance, value.trim()),
},
}
},
})
}@Trimmed() @State() email = '' // @State runs first, @Trimmed wraps its accessorA binding without a descriptor leaves the property as it is, which is how observers such as @Debug attach to a field.
Plain fields only
Field decorators apply to plain class fields. createFieldDecorator, @State(), @Prop() and every built-in field decorator throw a TypeError when the class is defined if they are put on an accessor, a method or a getter:
@State() decorates plain class fields only: remove `accessor` from "count".PraxisJS never needs the accessor keyword: decorators define the property they need on each instance.
Writing against the TC39 API directly
A field decorator written without createFieldDecorator should use the same two helpers the built-ins use. assertFieldContext(context, label) gives the error above, and addClaimableInitializer(context, init) registers the per-instance setup so that, with @praxisjs/vite-plugin, it runs in place of the field's own definition: the instance never holds a plain value that the decorator then replaces, which would slow down property access for every instance.
import { addClaimableInitializer, assertFieldContext } from '@praxisjs/decorators'
export function ReadOnly() {
return function (_value: undefined, context: ClassFieldDecoratorContext) {
assertFieldContext(context, '@ReadOnly()')
const slot = Symbol(String(context.name))
const descriptor = {
get(this: Record<symbol, unknown>) { return this[slot] },
enumerable: true,
configurable: true,
}
addClaimableInitializer(context, function (initial) {
(this as Record<symbol, unknown>)[slot] = initial
Object.defineProperty(this, context.name, descriptor)
})
}
}The function must define the field's property itself, since the field may not exist yet when it runs.
Method decorators
setup: one replacement for every instance
setup(original, context) returns the method that runs in place of original. It is created once, installed on the class, and this is the instance:
import { createMethodDecorator } from '@praxisjs/decorators'
export function Confirm(message: string) {
return createMethodDecorator({
label: '@Confirm()',
setup(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)
}Reading the method from an instance returns it bound to that instance, the same function on every read, so onClick={this.deleteItem} works without an arrow.
Keep per-instance state in a symbol slot on this rather than in a WeakMap:
const calls = Symbol('calls')
export function CountCalls() {
return createMethodDecorator({
setup(original) {
return function (this: { [calls]?: number }, ...args: unknown[]) {
this[calls] = (this[calls] ?? 0) + 1
return original.apply(this, args)
}
},
})
}To clean up on unmount, return a MethodSetup with the method and lifecycle hooks:
export function Later(ms: number) {
return createMethodDecorator({
setup(original, context) {
const timer = Symbol(`timer.${String(context.name)}`)
return {
method(this: Slots, ...args: unknown[]) {
clearTimeout(this[timer] as number | undefined)
this[timer] = setTimeout(() => original.apply(this, args), ms)
},
onUnmount(instance) {
clearTimeout((instance as Slots)[timer] as number | undefined)
},
}
},
})
}wrap: one wrapper per instance
wrap(original, instance, name) runs for each instance while it is built and returns that instance's function. Use it when the wrapper must close over the instance:
export function Logged() {
return createMethodDecorator({
wrap(original, instance, name) {
const tag = `${instance.constructor.name}.${name}`
return (...args: unknown[]) => {
console.log(tag, args)
return original.apply(instance, args)
}
},
})
}Both forms stack with built-in method decorators in any order (@Once() @Logged() load()), work on static and #private methods, and keep vi.spyOn(instance, 'method') working.
Lifecycle method decorators
createLifecycleMethodDecorator({ register }) registers a method on every mount and undoes it on unmount. register(callback, instance, name) receives the method bound to the instance and returns what undoes the registration, if anything.
import { createLifecycleMethodDecorator } from '@praxisjs/decorators'
export function OnResize() {
return createLifecycleMethodDecorator({
label: '@OnResize()',
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} />
}
}The second type parameter types the callback's arguments, and TypeScript then checks the decorated method against them:
export function OnKey(key: string) {
return createLifecycleMethodDecorator<StatefulComponent, [event: KeyboardEvent]>({
register(callback) {
const listener = (event: KeyboardEvent) => { if (event.key === key) callback(event) }
window.addEventListener('keydown', listener)
return () => { window.removeEventListener('keydown', listener) }
},
})
}Mounting twice without an unmount in between stops the first registration before starting the next one.
Getter decorators
setup(original, context) returns the replacement getter. It is installed on the class, so neither building an instance nor reading the getter allocates anything:
import { createGetterDecorator } from '@praxisjs/decorators'
export function Clamp(min: number, max: number) {
return createGetterDecorator<object, number>({
label: '@Clamp()',
setup(original) {
return function (this: object) {
return Math.min(max, Math.max(min, original.call(this)))
}
},
})
}@Component()
class Slider extends StatefulComponent {
@State() raw = 0
@Clamp(0, 100)
get value() { return this.raw } // always between 0 and 100
render() {
return <p>{() => this.value}</p>
}
}setup can also return a GetterSetup (get, set, init, onMount, onUnmount, the same keys as a field's) when the getter needs per-instance state. Without get, the original getter stays.
The older form, wrap(original, instance), is still supported. It is called on every read and returns a function that produces the value, so each read allocates: prefer setup.
Writable getter decorators
createWritableGetterDecorator({ setup }) returns a GetterSetup whose set handles writes. get, set and init are shared like a field's:
import { createWritableGetterDecorator } from '@praxisjs/decorators'
import { signal } from '@praxisjs/core/internal'
import type { Signal } from '@praxisjs/shared'
export function Overridable() {
return createWritableGetterDecorator({
label: '@Overridable()',
setup(original, context) {
const pinned = Symbol(`pinned.${String(context.name)}`)
return {
get(this: Slots) {
const value = (this[pinned] as Signal<unknown>)()
return value === undefined ? original.call(this) : value
},
set(this: Slots, value) {
(this[pinned] as Signal<unknown>).set(value)
},
init(instance) {
(instance as Slots)[pinned] = signal(undefined)
},
}
},
})
}@Component()
class PageHeader extends StatefulComponent {
@State() page = { title: 'Home' }
@Overridable()
get title(): string | undefined { return this.page.title }
set title(_value: string | undefined) { /* replaced by @Overridable() */ }
render() {
return (
<div>
<h1>{() => this.title}</h1>
<button onClick={() => { this.title = 'Custom' }}>Pin title</button>
<button onClick={() => { this.title = undefined }}>Reset</button>
</div>
)
}
}TypeScript treats a getter without a set as read-only, and a decorator cannot change that. Declare a set next to the getter so assignments type-check: the decorator's set replaces it.
The older bind(instance, name, original) form returns a { get, set? } pair per instance. Its pair is now read through one accessor on the class instead of being defined on each instance.
Getter observers
createGetterObserverDecorator reacts to a getter without changing its value. register(getter, instance, name) runs on every mount, once the instance's fields are set, and returns what stops it on unmount (a function or an effect):
import { createGetterObserverDecorator } from '@praxisjs/decorators'
import { effect } from '@praxisjs/core/internal'
export function LogChange() {
return createGetterObserverDecorator({
register(getter, instance, name) {
return effect(() => { console.log(`[${name}]`, getter.call(instance)) })
},
})
}@LogChange()
@Computed()
get filteredItems() {
return this.items.filter((i) => i.active)
}observe(getter, instance, name) runs instead while each instance is built, before its fields are initialized. A function it returns runs on unmount.
Class decorators
Extend ClassBehavior and implement the hooks you need. Hooks defined on the behavior receive the instance and allocate nothing per instance:
ClassBehavior method | Runs |
|---|---|
onMount(instance) / onUnmount(instance) | on every mount / unmount, after the class's own hook |
render(instance, originalRender) | on every render, around the class's own output |
create(instance) | once per instance; returns a ClassEnhancement (onMount, onUnmount, render(originalRender)) whose closures hold per-instance state |
initialize(Enhanced, original) | once, when the class is decorated; for static setup such as registration |
import { createClassDecorator, ClassBehavior } from '@praxisjs/decorators'
class AnalyticsBehavior extends ClassBehavior {
constructor(private readonly name: string) { super() }
onMount() { analytics.track('mount', { component: this.name }) }
onUnmount() { analytics.track('unmount', { component: this.name }) }
}
export function Analytics(name: string) {
return createClassDecorator(new AnalyticsBehavior(name))
}@Analytics('UserDashboard')
@Component()
class UserDashboard extends StatefulComponent { /* ... */ }To wrap rendering, implement render:
class TimedRender extends ClassBehavior {
render(instance: RootComponent, originalRender: () => Node | Node[] | null) {
const start = performance.now()
const nodes = originalRender()
console.log(instance.constructor.name, performance.now() - start)
return nodes
}
}A behavior with only initialize leaves the class as it is. Any other hook makes the decorator return a subclass that keeps the original name, so put class decorators above @Component() and use them as real decorators (not called by hand with the result discarded).
Typing
The builders' type parameters say which members a decorator accepts, and TypeScript reports a misplaced decorator in the editor:
| Builder | Type parameters | Rejects |
|---|---|---|
createFieldDecorator<Host, Value> | class, field type | @SessionValue('k') count = 0 (a number field for a string decorator) |
createMethodDecorator<Host, M> | class, method type | @OnlyPositive() name(s: string) with M = (n: number) => void |
createLifecycleMethodDecorator<Host, Args> | class, callback arguments | a method whose parameters don't match Args |
createGetterDecorator<Host, Value> | class, getter type | @Clamp(0, 1) get label(): string |
Value is inferred from get and set when it is not given. A method decorator's M is never inferred from setup, so a decorator fits every method unless M is given. Every decorator also checks the member kind at runtime, for plain JavaScript and builds that skip type checking.
FieldDecorator<Host, Value>, MethodDecorator<Host, M> and GetterDecorator<Host, Value> name the returned decorator types, for an explicit return annotation:
import type { FieldDecorator } from '@praxisjs/decorators'
export function SessionValue(key: string): FieldDecorator<object, string> { /* ... */ }Performance
A field decorator built with bind creates a new accessor pair for every instance, so instances end up with different hidden classes and every read of the field becomes polymorphic. With setup, every instance shares the decorator's functions and one hidden class.
Measured with Node 22 on a minified build, three decorated members per class (lower is better):
| Building one instance | bind / wrap | setup |
|---|---|---|
| field decorator (signal per field) | 870 ns | 320 ns |
| method decorator | 71 ns | 31 ns |
| writable getter decorator | 137 ns | 57 ns |
| getter decorator, per read | 10 ns | 0.9 ns |
Guidelines:
- Create per-instance state in
init, in symbol slots, for every instance, even when it starts empty (instance[slot] = undefined). Adding a slot later, on first use, gives instances different shapes. - Don't create closures inside a shared
getorset: a closure there makes every call allocate. Move it into a helper function, as@Computed()does when it creates its computed on first read. signal()allocates a few closures per call. In a hot decorator,SignalNodewithreadSignal/writeSignalfrom@praxisjs/core/internalstores the same state in one object.onMount/onUnmounthooks cost two small closures per instance, so add them only when the decorator needs them.
Testing a custom decorator
Decorated classes need the decorator lowering in Vitest too: register decoratorLoweringPlugin() as described in Using decorators in Vitest. Then build instances and call their lifecycle hooks by hand:
import { describe, it, expect } from 'vitest'
import { StatefulComponent } from '@praxisjs/core'
import { SessionValue } from './session-value'
class Search extends StatefulComponent {
@SessionValue('test:q') query = 'initial'
render() { return null }
}
describe('@SessionValue', () => {
it('starts from the stored value and writes through', () => {
sessionStorage.setItem('test:q', 'stored')
const search = new Search()
expect(search.query).toBe('stored')
search.query = 'next'
expect(sessionStorage.getItem('test:q')).toBe('next')
})
it('keeps instances independent', () => {
sessionStorage.clear()
const a = new Search()
const b = new Search()
a.query = 'a'
expect(b.query).toBe('initial')
})
it('names itself when misplaced', () => {
expect(() => {
class Wrong {
// @ts-expect-error -- a field decorator on a method
@SessionValue('k') run() {}
}
return Wrong
}).toThrow('@SessionValue() decorates class fields only, not the method "run".')
})
})For hooks, call instance.onMount?.() and instance.onUnmount?.() and check that what the decorator registered is undone. // @vitest-environment jsdom at the top of the file gives the test window, document and sessionStorage.
Migrating from createAccessorDecorator
@praxisjs/decorators 2.0 removes createAccessorDecorator and the @Computed({ get, set }) form declared on an accessor field. PraxisJS no longer needs the accessor keyword anywhere.
createAccessorDecorator → createFieldDecorator with setup. bind(instance, name, initialValue) becomes init(instance, initialValue) storing state in a slot, and the returned get/set become the shared get/set. Remove accessor from the fields.
// Before
export function Bounded(min: number, max: number) {
return createAccessorDecorator({
bind(_instance, _name, initialValue) {
const s = signal(clamp(initialValue as number))
return { get: () => s(), set: (v) => s.set(clamp(v as number)) }
},
})
}
@Bounded(0, 100) accessor volume = 80
// After
export function Bounded(min: number, max: number) {
const clamp = (v: number) => Math.min(max, Math.max(min, v))
return createFieldDecorator<object, number>({
setup(name) {
const slot = Symbol(`bounded.${name}`)
return {
get(this: Slots) { return (this[slot] as Signal<number>)() },
set(this: Slots, v) { (this[slot] as Signal<number>).set(clamp(v)) },
init(instance, initialValue) { (instance as Slots)[slot] = signal(clamp(initialValue)) },
}
},
})
}
@Bounded(0, 100) volume = 80If the per-instance closures are easier to keep, createFieldDecorator({ bind }) takes them almost unchanged: return { descriptor: { get, set } } instead of { get, set }.
@Computed({ get, set }) accessor x → a getter and a setter. Move get into a @Computed() getter and set into a set accessor with the same name:
// Before
@Computed({
get(this: Converter) { return (this.celsius * 9) / 5 + 32 },
set(this: Converter, f: number) { this.celsius = ((f - 32) * 5) / 9 },
})
accessor fahrenheit!: number
// After
@Computed()
get fahrenheit() { return (this.celsius * 9) / 5 + 32 }
set fahrenheit(f: number) { this.celsius = ((f - 32) * 5) / 9 }Field decorators already threw on accessor members, so no other code changes.
Internal API reference
Decorator implementations commonly use these internal primitives:
| Import | From | Description |
|---|---|---|
signal | @praxisjs/core/internal | Reactive state for a decorator's slot. |
SignalNode, readSignal, writeSignal | @praxisjs/core/internal | The same state as signal() in a single object, for hot decorators. |
computed | @praxisjs/core/internal | Derive a memoized value from signals. |
effect | @praxisjs/core/internal | Run side effects that re-run when signals change. Returns a stop function. |
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