PraxisJS

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/core
pnpm add @praxisjs/decorators @praxisjs/core
yarn add @praxisjs/decorators @praxisjs/core
bun add @praxisjs/decorators @praxisjs/core
FactoryApplied tobind / wrap calledUse when
createFieldDecoratorplain fieldonce per instancereplacing a field = value with custom reactive behavior
createMethodDecoratormethodonce per instancewrapping a method with cross-cutting logic
createLifecycleMethodDecoratormethodonce per instance (in onMount)auto-registering a method as an event listener
createGetterDecoratorget accessoron every readtransforming or constraining a getter's return value
createWritableGetterDecoratorget accessoronce per instanceintercepting both reads and writes; setter owned by the decorator
createGetterObserverDecoratorget accessoronce per instanceobserving a getter for side effects without changing its value
createAccessorDecoratoraccessor fieldonce per instance (lazy)owning the full state of a writable reactive field
createClassDecoratorclassonce per instanceaugmenting lifecycle hooks or wrapping render()

Internal APIs

Decorator implementations have access to the reactive engine through @praxisjs/core/internalsignal, 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).

Storybook
Live demo — createFieldDecorator (@SessionValue)

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.

Storybook
Live demo — createLifecycleMethodDecorator (@OnResize)

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
  }
}
Storybook
Live demo — createGetterDecorator (@Clamp)

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:

ImportFromDescription
signal@praxisjs/core/internalCreate reactive state inside a decorator binding.
computed@praxisjs/core/internalDerive a memoized value from signals.
effect@praxisjs/core/internalRun side effects that re-run when signals change.
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