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