Morphos & Kosmesis are here.Explore
PraxisJS

State & Props

Decorators for managing reactive state and external props — @State, @Prop, @FunctionProp, @Computed, @Persisted, @History, @Resource, @Synced, and @DeepState.

State & Props

Decorators for declaring reactive state and receiving external props. All imported from @praxisjs/decorators.

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

@State()

Turns a class field into a reactive signal. Reading it inside an arrow function in JSX subscribes to future updates. Writing to it notifies all subscribers.

@Component()
class Toggle extends StatefulComponent {
  @State() open = false

  render() {
    return (
      <button onClick={() => { this.open = !this.open }}>
        {() => this.open ? 'Close' : 'Open'}
      </button>
    )
  }
}

Arrays and objects need new references

@State tracks reference equality. Mutate arrays and objects by replacing them:

// ✅ triggers update
this.items = [...this.items, newItem]
this.config = { ...this.config, theme: 'dark' }

// ❌ no update — same reference
this.items.push(newItem)
this.config.theme = 'dark'
Storybook
Live demo — @State

@Prop()

Declares an external prop received from the parent. The initialized value is the default when the parent doesn't pass one.

@Component()
class Button extends StatefulComponent {
  @Prop() label = 'Click me'
  @Prop() disabled = false

  render() {
    return (
      <button disabled={() => this.disabled}>
        {() => this.label}
      </button>
    )
  }
}

// Usage:
<Button label="Submit" disabled={false} />
Storybook
Live demo — @Prop

@FunctionProp()

Declares an external prop whose value is a function. Use it when the parent passes a callback, predicate, formatter, or filtering function and the component should receive that function itself.

@Prop() treats a function prop as a reactive getter and calls it when the field is read. @FunctionProp() keeps the function intact.

Non-function values

If the parent passes a non-function value, @FunctionProp() still returns it as-is but logs a console.warn in development (skipped when NODE_ENV === "production").

type Product = { name: string; price: number }
type ProductFilter = (product: Product) => boolean

@Component()
class ProductList extends StatefulComponent {
  @Prop() items: Product[] = []
  @FunctionProp() filter: ProductFilter = () => true

  render() {
    return (
      <ul>
        {() => this.items
          .filter((item) => this.filter(item))
          .map((item) => <li>{item.name}</li>)}
      </ul>
    )
  }
}

// Usage:
<ProductList
  items={products}
  filter={(product) => product.price < 100}
/>
Storybook
Live demo — @FunctionProp

@Computed()

A derived getter cached as a reactive computed(). Recomputes only when its signal dependencies change. When multiple dependencies change in the same synchronous block, subscribers are notified once with the final value.

@Component()
class Cart extends StatefulComponent {
  @State() items: { name: string; price: number }[] = []

  @Computed()
  get total() {
    return this.items.reduce((sum, i) => sum + i.price, 0)
  }

  render() {
    return <p>Total: ${() => this.total}</p>
  }
}
Storybook
Live demo — @Computed

Writable computed — getter + setter pair

Add a plain set accessor alongside the decorated getter. TypeScript sees the property as read-write, and @Computed() handles only the reactive caching on the getter side.

@Component()
class TemperatureConverter extends StatefulComponent {
  @State() celsius = 0

  @Computed()
  get fahrenheit(): number {
    return (this.celsius * 9) / 5 + 32
  }

  set fahrenheit(value: number) {
    this.celsius = ((value - 32) * 5) / 9
  }

  render() {
    return (
      <div>
        <input
          type="number"
          value={() => this.celsius}
          onInput={(e: Event) => { this.celsius = Number((e.target as HTMLInputElement).value) }}
        />
        <input
          type="number"
          value={() => this.fahrenheit}
          onInput={(e: Event) => { this.fahrenheit = Number((e.target as HTMLInputElement).value) }}
        />
      </div>
    )
  }
}

The setter writes back to celsius, which reactively invalidates the cached getter — so fahrenheit recomputes on the next read. TypeScript is fully aware of the setter, so assigning to this.fahrenheit type-checks correctly.

One source of truth

The setter does not store its own value. It updates the signals that the getter depends on, keeping the derived value always consistent.

Storybook
Live demo — @Computed with setter

@Computed() stays on the class, so a subclass can override the getter and read the cached value through super.fahrenheit.


@Persisted(key?, options?)

Like @State, but persisted to localStorage and restored on page load. key defaults to the property name.

@Component()
class Settings extends StatefulComponent {
  @Persisted() theme = 'light'
  @Persisted('app:fontSize') fontSize = 14

  render() {
    return (
      <select
        value={() => this.theme}
        onChange={(e) => { this.theme = (e.target as HTMLSelectElement).value }}
      >
        <option value="light">Light</option>
        <option value="dark">Dark</option>
      </select>
    )
  }
}

Setting the value to null or undefined removes the entry from localStorage.

OptionTypeDefaultDescription
serialize(v: T) => stringJSON.stringifyCustom serializer
deserialize(s: string) => TJSON.parseCustom deserializer
syncTabsbooleantrueSync across browser tabs via storage event
writeDelaynumber0Milliseconds to hold back localStorage writes. Writes inside the window are coalesced into one with the latest value; pending data is flushed on pagehide and on unmount. 0 writes on every assignment

Set writeDelay on fields bound to fast input (a text box, a slider) so each keystroke doesn't serialize and write synchronously.

With syncTabs on, the storage listener is removed when the component unmounts, so mounting and unmounting a component repeatedly does not accumulate listeners.

Storybook
Live demo — @Persisted

@History(fieldName, limit?)

Adds undo/redo to a @State property. Tracking stops when the component unmounts. Declare a separate field decorated with @History('fieldName') and type it with HistoryOf<Class, 'fieldName'> for full intellisense.

import { History, HistoryOf } from '@praxisjs/decorators'

@Component()
class Editor extends StatefulComponent {
  @State()
  text = ''

  @History('text', 100)
  textHistory!: HistoryOf<Editor, 'text'>

  render() {
    return (
      <div>
        <textarea
          value={() => this.text}
          onInput={(e) => { this.text = (e.target as HTMLTextAreaElement).value }}
        />
        <button
          onClick={() => this.textHistory.undo()}
          disabled={() => !this.textHistory.canUndo()}
        >
          Undo
        </button>
        <button
          onClick={() => this.textHistory.redo()}
          disabled={() => !this.textHistory.canRedo()}
        >
          Redo
        </button>
      </div>
    )
  }
}

The HistoryOf type exposes: .undo(), .redo(), .canUndo(), .canRedo(). limit sets the maximum number of history entries (default: unlimited).

Storybook
Live demo — @History

@Resource(fetcher, options?)

Binds an async resource to the field. Automatically re-fetches when any signal read inside the fetcher changes.

Pass self as the first parameter to receive the component instance — signal reads on self become reactive dependencies:

@Component()
class PostList extends StatefulComponent {
  @State() page = 1

  @Resource((self: PostList) =>
    fetch(`/api/posts?page=${self.page}`).then(r => r.json() as Promise<Post[]>)
  )
  posts!: ResourceInstance<Post[]>

  render() {
    return (
      <div>
        {() => this.posts.pending() && <Spinner />}
        {() => this.posts.error() && <p>Failed to load posts.</p>}
        {() => this.posts.data()?.map(p => <PostCard post={p} />)}
        <button onClick={() => this.page++}>Next page</button>
      </div>
    )
  }
}

For fetchers with no component dependency, the zero-argument form works without self:

@Resource(() => fetch('/api/config').then(r => r.json() as Promise<Config>))
config!: ResourceInstance<Config>
OptionDefaultDescription
immediatetrueFetch on initialization
initialDatanullValue of .data() before the first fetch completes
keepPreviousDatafalseKeep old data visible while refetching

The field exposes: .data(), .pending(), .error(), .status(), .refetch(), .cancel(), .mutate(value).

→ See Async Data for the complete guide.

Storybook
Live demo — @Resource

@Synced(channelName?)

Like @State, but the value is synced in real-time across all open browser tabs via BroadcastChannel. channelName defaults to the field name.

@Component()
class CartButton extends StatefulComponent {
  @Synced('cart') items: Product[] = []

  render() {
    return (
      <button onClick={() => { this.items = [...this.items, newItem] }}>
        Cart ({() => this.items.length})
      </button>
    )
  }
}

When any tab writes to this.items, all other open tabs update automatically. Combine with @Persisted for both persistence and live sync:

@Synced('cart')
@Persisted('cart')
items: Product[] = []

Serialization uses JSON.stringify/JSON.parse. Values that aren't JSON-serializable (functions, class instances, undefined) aren't supported.

Storybook
Live demo — @Synced

@Ref<T>()

Creates a DOM element ref. The decorated field becomes a Ref<T> object — a callable function that you pass directly to the JSX ref prop, with a .current property holding the element after mount.

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

@Component()
class ResizeWatcher extends StatefulComponent {
  @Ref<HTMLDivElement>()
  containerRef!: Ref<HTMLDivElement>

  render() {
    return (
      <div ref={this.containerRef}>
        {/* this.containerRef.current holds the element after mount */}
      </div>
    )
  }
}

Pass the field name as a string to @Compose — the composable receives the ref object and reads .current on mount:

import { Component, Compose, Ref } from '@praxisjs/decorators'
import { ElementSize } from '@praxisjs/composables'

@Component()
class Card extends StatefulComponent {
  @Ref<HTMLDivElement>()
  cardRef!: Ref<HTMLDivElement>

  @Compose(ElementSize, 'cardRef')
  size!: ElementSize

  render() {
    return (
      <div ref={this.cardRef}>
        Width: {() => this.size.width}px
      </div>
    )
  }
}

Type: Ref<T> is ((el: T | null) => void) & { current: T | null } — it is both the callback for ref={} and holds the element in .current. Import Ref as both the decorator and the type annotation from a single import.

For refs outside of a class (module-level constants, options passed to @Lazy), use createRef<T>():

import { createRef } from '@praxisjs/decorators'

const scrollRoot = createRef<HTMLDivElement>()

const LazyWidget = Lazy({ root: scrollRoot })(Widget, {} as ClassDecoratorContext)

// In render:
<div ref={scrollRoot}>...</div>

@DeepState()

Like @State, but uses a deep Proxy so nested mutations are reactive without needing to create new references.

@Component()
class ThemeEditor extends StatefulComponent {
  @DeepState() config = { theme: { mode: 'light', accent: '#0070f3' }, fontSize: 14 }

  render() {
    return (
      <div>
        <p>Mode: {() => this.config.theme.mode}</p>
        <button onClick={() => { this.config.theme.mode = 'dark' }}>Dark</button>
        <button onClick={() => { this.config.fontSize++ }}>Larger text</button>
      </div>
    )
  }
}

Any mutation at any depth — including push, index assignment, and property deletion — triggers reactive updates. Assigning a value a property already holds, or deleting a key that doesn't exist, does not. Reading a nested object returns the same reference every time (this.config.theme === this.config.theme), so identity comparisons behave as you'd expect.

@State vs @DeepState

Prefer @State with immutable patterns (this.items = [...this.items, x]) — it's explicit and fine-grained. Use @DeepState when working with deeply nested structures where immutable patterns become too verbose.

@DeepState is coarse-grained: any nested mutation re-runs all effects that read the field, regardless of which property changed.

Limitations

Map, Set, and class instances are not tracked deeply — only plain objects and arrays.

Storybook
Live demo — @DeepState

On this page