PraxisJS

Store

@praxisjs/store — class-based singleton stores with @Storable and @Store. Define reactive state with @State, inject anywhere with @Store or store().

Store

Class-based singleton state management. Mark a store class with @Storable, inject it into any component with @Store. All components reading the same store stay in sync automatically.

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

Defining a store with @Storable()

Extends ReactiveStore to use @State, @Computed, and @DeepState on store fields. The class is instantiated once on first use and the same instance is shared everywhere.

import { Storable, ReactiveStore } from '@praxisjs/store'
import { State, Computed } from '@praxisjs/decorators'

@Storable()
class CartStore extends ReactiveStore {
  @State() items: Product[] = []
  @State() discount = 0

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

  addItem(product: Product) {
    this.items = [...this.items, product]
  }

  removeItem(id: string) {
    this.items = this.items.filter(i => i.id !== id)
  }

  applyDiscount(rate: number) {
    this.discount = rate
  }
}

Why extend ReactiveStore?

ReactiveStore provides the internal state tracking that @State and @DeepState require on non-component classes. It also lets TypeScript validate that reactive decorators are used only on classes designed to hold reactive state.

Storybook
Live demo — @Storable / @Store

Injecting a store with @Store(StoreClass)

Injects the singleton store instance into a component field. The store is created on first access — subsequent injections return the same instance.

import { Store } from '@praxisjs/store'

@Component()
class CartButton extends StatefulComponent {
  @Store(CartStore) cart!: CartStore

  render() {
    return (
      <button onClick={() => this.cart.addItem(product)}>
        Add to cart ({() => this.cart.items.length})
      </button>
    )
  }
}

Any component that reads a reactive property from the store updates automatically when that property changes — no subscriptions, no context providers.


store(StoreClass)

Resolves the singleton store instance imperatively — no class field or decorator required. Use this anywhere @Store is unavailable: route guards, plain functions, or services.

import { store } from '@praxisjs/store'

const cart = store(CartStore)
cart.addItem(product)

In a route guard:

import { store } from '@praxisjs/store'

{
  path: '/checkout',
  component: CheckoutPage,
  beforeEnter: async () => {
    const cart = store(CartStore)
    return cart.items.length > 0 ? true : '/'
  },
}

store() and @Store share the same registry — they always return the same instance.


Sharing state across components

Multiple components can inject the same store. They all share the same instance and react to the same changes:

@Component()
class CartSummary extends StatefulComponent {
  @Store(CartStore) cart!: CartStore

  render() {
    return (
      <div>
        <p>{() => this.cart.items.length} items</p>
        <p>Total: ${() => this.cart.total.toFixed(2)}</p>
      </div>
    )
  }
}

@Component()
class CheckoutPage extends StatefulComponent {
  @Store(CartStore) cart!: CartStore

  render() {
    return (
      <div>
        {() => this.cart.items.map(item => <CartItem item={item} />)}
        <button onClick={() => this.cart.applyDiscount(0.1)}>Apply 10% off</button>
      </div>
    )
  }
}

When any component calls cart.addItem(), both CartSummary and CheckoutPage update automatically — because they both read reactive properties from the shared store instance.


Plugins

Plugins extend every store created after they are registered. Call useStorePlugin once at app startup — plugins apply to all stores created from that point on.

import { useStorePlugin } from '@praxisjs/store'
import type { StorePlugin } from '@praxisjs/store'

useStorePlugin(myPlugin)

A plugin is a plain object with a name and any combination of four hooks:

import type { StorePlugin } from '@praxisjs/store'

const myPlugin: StorePlugin = {
  name: 'my-plugin',

  // Called once when the store is first created.
  // Use `extend` to add properties to the store (e.g. $persist, $undo).
  onInit({ store, storeName, extend }) {
    extend({ $tag: storeName })
  },

  // Called after every state mutation (direct assignment or $patch).
  onMutation({ key, value, prevValue, storeName }) { /* ... */ },

  // Called before a store method executes.
  onAction({ name, args, storeName }) { /* ... */ },

  // Called after a store method returns (or resolves, if async).
  // `error` is set when an async method rejects.
  onActionDone({ name, result, error, storeName }) { /* ... */ },
}

Plugin hooks

HookWhen it runsReceives
onInitOnce, when the store is first createdstore, storeName, extend(props)
onMutationAfter each signal assignmentkey, value, prevValue, storeName
onActionBefore each method callname, args, storeName
onActionDoneAfter each method returns/resolvesname, result, error?, storeName

Plugin registration order matters

Plugins are snapshotted at store creation time. Register all plugins before creating stores (or before the first store() call for class-based stores). Plugins registered later will not be applied to already-created stores.

Logger plugin

import { useStorePlugin } from '@praxisjs/store'

useStorePlugin({
  name: 'logger',
  onMutation({ storeName, key, value, prevValue }) {
    console.log(`[${storeName}] ${key}: ${String(prevValue)} → ${String(value)}`)
  },
  onAction({ storeName, name, args }) {
    console.log(`[${storeName}] ${name}(${args.join(', ')})`)
  },
})
Storybook
Live demo — Logger plugin

Persistence plugin

import { useStorePlugin } from '@praxisjs/store'

useStorePlugin({
  name: 'persist',
  onInit({ store, storeName }) {
    const saved = localStorage.getItem(storeName)
    if (saved) Object.assign(store, JSON.parse(saved) as Record<string, unknown>)
  },
  onMutation({ storeName, key, value }) {
    const current = JSON.parse(localStorage.getItem(storeName) ?? '{}') as Record<string, unknown>
    localStorage.setItem(storeName, JSON.stringify({ ...current, [key]: value }))
  },
})
Storybook
Live demo — Persistence plugin

Extending stores with extend()

onInit receives an extend(props) function. Call it to add extra properties to every store instance — useful for adding $undo, $persist, or devtools helpers:

useStorePlugin({
  name: 'undo',
  onInit({ store, extend }) {
    const history: unknown[] = []
    extend({
      $undo() {
        if (history.length) Object.assign(store, history.pop() as object)
      },
    })
  },
  onMutation({ store }) {
    history.push({ ...(store as Record<string, unknown>) })
  },
})
Storybook
Live demo — extend() / Undo plugin

clearPlugins()

Removes all registered plugins. Primarily used in tests to isolate plugin state between test cases.

import { clearPlugins } from '@praxisjs/store'

beforeEach(() => clearPlugins())

Auth store example

A common pattern — a store that holds authentication state and exposes derived values:

@Storable()
class AuthStore extends ReactiveStore {
  @State() user: User | null = null

  @Computed()
  get isLoggedIn() { return this.user !== null }

  login(user: User) { this.user = user }
  logout() { this.user = null }
}

// In any component:
@Store(AuthStore) auth!: AuthStore

render() {
  return () => this.auth.isLoggedIn
    ? <UserMenu user={this.auth.user!} />
    : <LoginButton />
}

On this page