Morphos & Kosmesis are here.Explore
PraxisJS

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/core
pnpm add @praxisjs/decorators @praxisjs/core
yarn add @praxisjs/decorators @praxisjs/core
bun add @praxisjs/decorators @praxisjs/core

Which builder?

I want to…UseExample
Give a field custom reactive get/set behaviorcreateFieldDecorator@SessionValue('key') query = ''
Wrap a field another decorator already set upcreateFieldDecorator 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 unmountcreateLifecycleMethodDecorator@OnResize() recalculate()
Change what a getter returnscreateGetterDecorator@Clamp(0, 100) get value()
Make a getter writable, with the decorator handling writescreateWritableGetterDecorator@Overridable() get title()
React to a getter's value without changing itcreateGetterObserverDecorator@LogChange() get total()
Add mount/unmount behavior or wrap render() for a whole classcreateClassDecorator@Analytics('Dashboard')
Reuse reactive logic as an object with its own statea 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, and this is the instance. Per-instance state lives in symbol-keyed slots on the instance. This is the recommended form.
  • bind (wrap for 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 keyRunsNotes
get()on every readthis is the instance. Leave out both get and set to keep the plain field.
set(value)on every writeWithout it, a write throws a TypeError, as for any getter without a setter.
init(instance, initialValue)once per instance, before get/set are installedCreate 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 / unmountChained after the instance's own hooks.
enumerableDefaults 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.

Storybook
Live demo: createFieldDecorator (@SessionValue)

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 accessor

A 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)
        },
      }
    },
  })
}
Storybook
Live demo: createMethodDecorator (@CountCalls)

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.

Storybook
Live demo: createLifecycleMethodDecorator (@OnResize)

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.

Storybook
Live demo: createGetterDecorator (@Clamp)

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.

Storybook
Live demo: createWritableGetterDecorator (@Overridable)

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 methodRuns
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).

Storybook
Live demo: createClassDecorator (@MountCounter)

Typing

The builders' type parameters say which members a decorator accepts, and TypeScript reports a misplaced decorator in the editor:

BuilderType parametersRejects
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 argumentsa 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 instancebind / wrapsetup
field decorator (signal per field)870 ns320 ns
method decorator71 ns31 ns
writable getter decorator137 ns57 ns
getter decorator, per read10 ns0.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 get or set: 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, SignalNode with readSignal / writeSignal from @praxisjs/core/internal stores the same state in one object.
  • onMount / onUnmount hooks 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 = 80

If 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:

ImportFromDescription
signal@praxisjs/core/internalReactive state for a decorator's slot.
SignalNode, readSignal, writeSignal@praxisjs/core/internalThe same state as signal() in a single object, for hot decorators.
computed@praxisjs/core/internalDerive a memoized value from signals.
effect@praxisjs/core/internalRun side effects that re-run when signals change. Returns a stop function.
batch@praxisjs/core/internalGroup multiple signal writes into a single notification.
peek@praxisjs/core/internalRead a signal without subscribing.
RootComponent@praxisjs/core/internalType the instance parameter in class decorators.
isSignal@praxisjs/shared/internalCheck if a value is a signal at runtime.
ComponentConstructor@praxisjs/shared/internalType a component class accepted as a parameter.

Full reference → @praxisjs/core/internal · @praxisjs/shared/internal

On this page