Morphos & Kosmesis are here.Explore
PraxisJS

Performance Decorators

Optimize rendering with @Lazy for viewport-deferred components, @Selector for keyed selection, and VirtualList for large lists.

Performance Decorators

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

@Lazy(placeholder?)

Defers rendering until the component scrolls into the viewport. A placeholder element is shown while the component is off-screen — preventing unnecessary work and keeping the initial render fast.

import { Lazy, Component } from '@praxisjs/decorators'

@Lazy(300)  // 300px placeholder height while off-screen
@Component()
class HeavyChart extends StatefulComponent {
  render() {
    return <canvas id="chart" />
  }
}

The argument is the placeholder height in pixels. Using a fixed height prevents layout shift when the component eventually renders.

Decorator order matters

@Lazy must be written above @Component(). Decorators apply bottom-up, so @Component() runs first to create the component, then @Lazy wraps it with intersection-based deferred rendering.

Scoped to a scroll container

By default, intersection is observed against the viewport. To scope it to a specific scrollable element, pass an options object:

const listRef = { current: null as HTMLDivElement | null }

@Lazy({ placeholder: 300, root: listRef, rootMargin: '100px' })
@Component()
class HeavyRow extends StatefulComponent { /* ... */ }

// In the parent render:
<div ref={(el) => { listRef.current = el }} style="overflow-y:auto;height:400px">
  <HeavyRow />
</div>
OptionTypeDefaultDescription
placeholdernumber200Placeholder height in px
root{ current: HTMLElement | null }null (viewport)Scroll container ref
rootMarginstring"100px"Extra margin before the component renders — pre-loads before it's visible

Good use cases: components below the fold, heavy data visualizations, third-party widgets.

Storybook
Live demo — @Lazy

@Selector(field, options?)

Turns one piece of state into a per-key check. Comparing a field against each row directly subscribes every row to that field, so changing it re-runs every row's binding:

// 1,000 rows → 1,000 bindings re-run on every selection
<tr class={() => (this.selected === row.id ? 'danger' : '')}>

@Selector gives each key its own subscriber list. When the field changes, only the bindings that read the previous key and the next key re-run — 2 instead of 1,000:

import { Component, Selector, State } from '@praxisjs/decorators'

@Component()
class Table extends StatefulComponent {
  @State() rows: Row[] = []
  @State() selected = 0
  @Selector('selected') isSelected!: Selector<number>

  render() {
    return (
      <tbody>
        {() => this.rows.map((row) => (
          <tr class={() => (this.isSelected(row.id) ? 'danger' : '')}>
            <td onClick={() => { this.selected = row.id }}>{row.label}</td>
          </tr>
        ))}
      </tbody>
    )
  }
}

The argument names the field to watch — any reactive field works (@State, @Prop, @Computed, store fields). The decorated field is a function (key) => boolean, typed with the Selector<K> type exported alongside the decorator. Writing to this.selected is unchanged; only the reads go through this.isSelected(key).

Called outside a binding (in an event handler or in render() itself), this.isSelected(key) simply returns the comparison without subscribing.

Custom equality

By default a key is selected when it is the same value as the field (the same rule Map uses for keys). Pass equals to decide differently — for example, multi-selection:

@State() selectedIds: number[] = []
@Selector('selectedIds', { equals: (id: number, ids: number[]) => ids.includes(id) })
isSelected!: Selector<number>

With a custom equals, every key currently in use is checked when the field changes, and only the bindings whose result flipped re-run. The default comparison skips that walk and looks up the two affected keys directly.

OptionTypeDefaultDescription
equals(key: K, value: T) => booleanSameValueZeroWhether key counts as selected for the field's value value

No cleanup needed

Each key's subscriber list is removed as soon as the last binding reading it is disposed, so removed rows leave nothing behind. When no binding reads any key, the selector also stops watching the field (one microtask later, so a list re-render does not drop and re-create the subscription).

Storybook
Live demo — @Selector

Large list virtualization

For virtualizing long lists, use the VirtualList composable from @praxisjs/composables. It exposes reactive signals (visibleItems, totalHeight, offsetTop, offsetBottom) that you render with normal JSX — no custom renderItem callback, no special conventions.

→ See VirtualList for the full guide and examples.

Storybook
Live demo — VirtualList

On this page