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.


@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 read-only 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

@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.

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

  @Resource(() => fetch(`/api/posts?page=${this.page}`).then(r => r.json()))
  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>
    )
  }
}
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

@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