Morphos & Kosmesis are here.Explore
PraxisJS

Utility Decorators

General-purpose method decorators — @Bind, @Log, @Once, @Memo, and @Retry.

Utility Decorators

General-purpose decorators for cross-cutting concerns: binding, logging, caching, and retry logic.

npm install @praxisjs/decorators
pnpm add @praxisjs/decorators
yarn add @praxisjs/decorators
bun add @praxisjs/decorators

@Bind()

Binds the method to the instance. Useful when you need to pass a method as a callback without wrapping it in an arrow function every time.

@Component()
class Panel extends StatefulComponent {
  @State() open = true

  @Bind()
  handleKeyDown(e: KeyboardEvent) {
    if (e.key === 'Escape') this.open = false
  }

  onMount() {
    // `this` is correct because of @Bind — no arrow wrapper needed
    window.addEventListener('keydown', this.handleKeyDown)
  }

  onUnmount() {
    window.removeEventListener('keydown', this.handleKeyDown)
  }

  render() {
    return () => this.open && <div class="panel">...</div>
  }
}

Every read returns the same bound function, so removeEventListener receives the listener that was added. The bound copy is created the first time the method is read from an instance, so instances that never read it pay nothing.

@Once, @Memo, @Log, @Retry, @Debounce, @Throttle and @Emit return bound methods the same way, so they can be passed as callbacks directly too.

Storybook
Live demo — @Bind

@Log(options?)

Logs method calls with arguments, return value, and execution time. Dev-only by default — stripped from production builds when devOnly: true.

@Log({ level: 'debug', time: true })
async fetchUser(id: number) {
  return fetch(`/api/users/${id}`).then(r => r.json())
}
// Logs: fetchUser(42) → { id: 42, name: 'Jane' } [12ms]
OptionTypeDefaultDescription
level'log' | 'debug' | 'warn''log'Console method to use
argsbooleantrueInclude arguments in the log
resultbooleantrueInclude the return value
timebooleanfalseInclude execution time in ms
devOnlybooleantrueSkip in production environments

@Once()

Ensures the method runs at most once per instance. The return value is cached and returned on all subsequent calls — the original function never runs again.

@Component()
class AppConfig extends StatefulComponent {
  @Once()
  async loadConfig() {
    const res = await fetch('/config.json')
    return res.json()
    // Fetched once — every subsequent call returns the cached result
  }

  onMount() {
    this.loadConfig().then(config => console.log(config))
    this.loadConfig().then(config => console.log(config))  // same result, no second request
  }
}
Storybook
Live demo — @Once

@Memo()

Memoizes the return value per unique argument combination. Each unique set of arguments gets its own reactive computed() — so if the method reads any @State or @Prop, the cache updates automatically when those signals change.

@Component()
class PriceList extends StatefulComponent {
  @State() discount = 0

  @Memo()
  discountedPrice(price: number) {
    return price * (1 - this.discount)
  }

  render() {
    return (
      <ul>
        <li>$100 → {() => this.discountedPrice(100)}</li>
        <li>$200 → {() => this.discountedPrice(200)}</li>
        <li>$500 → {() => this.discountedPrice(500)}</li>
      </ul>
    )
  }
}

When this.discount changes, all three cached values recompute. Each argument combination has an independent cache entry.

The cache keeps the 100 most recently used argument combinations; the least recently used entry is dropped once the limit is exceeded, and its subscriptions to the signals it read are released (unless a binding is still using it). Pass max to change the limit, or Infinity to keep every combination for the life of the component:

@Memo({ max: 50 })
search(query: string) {
  return this.items.filter((i) => i.name.includes(query))
}

How argument caching works

Arguments are serialized as a string key:

  • Objects/null → JSON.stringify (falls back to object identity for non-serializable values)
  • Symbols → symbol.toString()
  • Everything else → String(value)
Storybook
Live demo — @Memo

@Retry(maxAttempts, options?)

Automatically retries an async method on failure. The method is called again after delay ms, with the delay multiplied by backoff on each subsequent attempt.

@Retry(3, { delay: 500, backoff: 2 })
async saveData(data: object) {
  const res = await fetch('/api/save', {
    method: 'POST',
    body: JSON.stringify(data),
  })
  if (!res.ok) throw new Error(`Save failed: ${res.status}`)
}
// Attempts at: 0ms, 500ms, 1000ms — then throws if all fail
OptionTypeDescription
delaynumberWait (ms) before the first retry
backoffnumberMultiply delay by this factor on each retry (2 doubles it)
onRetry(error, attempt) => voidCalled before each retry attempt
Storybook
Live demo — @Retry

On this page