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/storepnpm add @praxisjs/storeyarn add @praxisjs/storebun add @praxisjs/storeDefining 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.
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
| Hook | When it runs | Receives |
|---|---|---|
onInit | Once, when the store is first created | store, storeName, extend(props) |
onMutation | After each signal assignment | key, value, prevValue, storeName |
onAction | Before each method call | name, args, storeName |
onActionDone | After each method returns/resolves | name, result, error?, storeName |
With plugins, a method read from the store is a wrapper bound to the store, and it is the same function on every read: onClick={cart.clear} works and keeps a stable identity. Assigning a field inside an effect does not subscribe that effect to the field — prevValue and anything the plugins read during onMutation are read untracked.
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(', ')})`)
},
})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 }))
},
})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>) })
},
})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 />
}Router
@praxisjs/router — signal-based client-side router. Configure routes with @Router, define lazy routes with @Lazy, annotate pages with @Route, inject reactive router state with @Router(), @Params, @Query, @Location, and navigate by name with push({ name }) or replace({ name }).
Dependency Injection
@praxisjs/di — decorator-based DI with @Injectable, @Inject, @InjectContainer, and @Scope for per-instance scoped containers. Use inject() for imperative resolution outside components.