PraxisJS

State & Props

Decorators for managing reactive state and external props — @State, @Prop, @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

@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

Compact form — @Computed({ get, set }) on accessor

Pass both get and set to the decorator factory and declare the field with the accessor keyword. TypeScript treats accessor fields as read-write, so no cast is needed.

@Computed({
  get(this: TemperatureConverter): number {
    return (this.celsius * 9) / 5 + 32
  },
  set(this: TemperatureConverter, fahrenheit: number) {
    this.celsius = ((fahrenheit - 32) * 5) / 9
  },
})
accessor fahrenheit!: number

The ! (definite assignment assertion) tells TypeScript that the decorator initializes the field, so no initializer expression is needed. At runtime, reads go through a reactive computed backed by get; writes call set directly.

accessor vs getter + setter

Both forms are fully TypeScript-compatible. Use the accessor form when you prefer to keep the getter formula and setter logic co-located inside the decorator options. Use the plain getter + setter pair when you want the getter formula visible as a class method for readability or when you need to call super.

Storybook
Live demo — @Computed({ get, set }) accessor form

@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
Storybook
Live demo — @Persisted

@History(fieldName, limit?)

Adds undo/redo to a @State property. 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.

@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