State Management
On this page 21
stx provides two levels of state management: local state with state() for page-specific reactivity, and stores with defineStore() for state shared across pages. This guide covers when to use each and how they work.
Decision Tree
Ask yourself one question: Is this state shared across pages?
- No --> Use
state()in your<script client>block - Yes --> Use
defineStore()in a store file
That is the entire decision. Do not overcomplicate it.
What Stays as Local state()
- Form input values (
state('')for a search box) - UI toggles (
state(false)for a dropdown or modal) - Loading flags (
state(true)while fetching page-specific data) - Page-specific data (
state(null)for API response data) - Temporary values that reset on navigation
What Goes in a Store
- Authentication state (current user, token, login status)
- Shopping cart contents
- User preferences (theme, language, sidebar state)
- Any data that must survive SPA navigation between pages
Local State with state()
The state() function creates a reactive signal. When its value changes, any template binding that reads it automatically updates.
<scriptclient
const count = state(0)
const name = state('World')
const items = state([])
function addItem(item) {
items.update(arr => [...arr, item])
}
</script>
<p>Hello, <spanx-text=""></span></p>
<p>Count: <spanx-text=""></span></p>
<buttonclick="">Increment</button>
Signal API
const count = state(0)
// Read the value
count() // 0
// Set a new value
count.set(5) // now 5
// Update based on current value
count.update(n => n + 1) // now 6
// Subscribe to changes
const unsub = count.subscribe((value, prev) => {
console.log(`Changed from ${prev} to ${value}`)
})
unsub() // stop listening
Derived State
Use derived() for values computed from other signals:
<scriptclient
const price = state(100)
const quantity = state(1)
const total = derived(() => price() * quantity())
const formatted = derived(() => `${total().toFixed(2)}`)
</script>
<p>Total: <spanx-text=""></span></p>
Derived signals automatically re-compute when any signal they read changes. You never manually invalidate them.
Effects
Use effect() to run side effects when signals change:
<scriptclient
const query = state('')
effect(() => {
const q = query()
if (q.length >= 3) {
fetch(`/api/search?q=${encodeURIComponent(q)}`)
.then(r => r.json())
.then(results => searchResults.set(results))
}
})
</script>
Stores with defineStore()
Stores are for state that must persist across SPA page navigations. They live in the stores/ directory (configurable via storesDir in your config).
Creating a Store
// stores/auth.ts
import { defineStore, state, derived } from '@stacksjs/stx'
export const useAuthStore = defineStore('auth', () => {
const user = state(null)
const token = state(null)
const isAuthenticated = derived(() => !!token())
async function login(email: string, password: string) {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const data = await res.json()
token.set(data.token)
user.set(data.user)
}
function logout() {
token.set(null)
user.set(null)
navigate('/login')
}
return { user, token, isAuthenticated, login, logout }
})
Using a Store
In any page's <script client> block, retrieve the store with useStore():
<scriptclient
const auth = useStore('auth')
onMount(() => {
if (!auth.isAuthenticated()) {
navigate('/login')
}
})
</script>
<divshow="">
<p>Welcome, <spanx-text=""></span></p>
<buttonclick="">Log out</button>
</div>
useStore() returns the store object. It throws an error if the store is not found -- it never returns null.
Auto-Discovery
stx automatically discovers store files:
- On startup, stx scans
storesDir(default:stores/) for.tsfiles - Each file is transpiled (TypeScript stripped) and bundled
- The bundled stores are injected as a
<script>tag before any<script client>blocks defineStore(),state(),derived(), anduseStore()are available as globals in the browser
This means you do not need to import anything in your .stx pages. The store is registered automatically:
<!-- No imports needed -- stores are globals -->
<scriptclient
const cart = useStore('cart')
</script>
In .ts files (the store definitions themselves), you do need explicit imports:
// stores/cart.ts
import { defineStore, state, derived } from '@stacksjs/stx'
export const useCartStore = defineStore('cart', () => {
// ...
})
Store Dependency Order
Stores are loaded in dependency order. If store A calls useStore('B'), store B is loaded first:
// stores/auth.ts -- loaded first (no useStore calls)
export const useAuthStore = defineStore('auth', () => {
const token = state(null)
return { token }
})
// stores/api.ts -- loaded second (depends on auth)
export const useApiStore = defineStore('api', () => {
const auth = useStore('auth')
async function fetchWithAuth(url) {
return fetch(url, {
headers: { Authorization: `Bearer ${auth.token()}` },
})
}
return { fetchWithAuth }
})
stx detects useStore() calls in store files and sorts them: stores without useStore() load first, stores with useStore() load after.
Persistence
Add { persist: true } to save store state to localStorage and restore it on page load:
// stores/preferences.ts
import { defineStore, state } from '@stacksjs/stx'
export const usePreferencesStore = defineStore('preferences', () => {
const theme = state('light')
const language = state('en')
const sidebarOpen = state(true)
return { theme, language, sidebarOpen }
}, { persist: true })
For fine-grained control:
defineStore('preferences', () => {
// ...
}, {
persist: {
pick: ['theme', 'language'], // Only persist these keys
storage: 'local', // 'local', 'session', or 'memory'
key: 'app-prefs', // Custom localStorage key
},
})
SSR Hydration
In SSR mode, stores can be hydrated from the server. The server injects window.__STX_STORE_STATE__ with initial store values, and the client picks them up on load.
Complete Example: E-Commerce Cart
Store Definition
// stores/cart.ts
import { defineStore, state, derived } from '@stacksjs/stx'
interface CartItem {
id: string
name: string
price: number
quantity: number
}
export const useCartStore = defineStore('cart', () => {
const items = state<CartItem[]>([])
const count = derived(() =>
items().reduce((sum, item) => sum + item.quantity, 0)
)
const total = derived(() =>
items().reduce((sum, item) => sum + item.price * item.quantity, 0)
)
function addItem(product: { id: string, name: string, price: number }) {
items.update(current => {
const existing = current.find(i => i.id === product.id)
if (existing) {
return current.map(i =>
i.id === product.id ? { ...i, quantity: i.quantity + 1 } : i
)
}
return [...current, { ...product, quantity: 1 }]
})
}
function removeItem(id: string) {
items.update(current => current.filter(i => i.id !== id))
}
function updateQuantity(id: string, quantity: number) {
if (quantity <= 0) return removeItem(id)
items.update(current =>
current.map(i => i.id === id ? { ...i, quantity } : i)
)
}
function clear() {
items.set([])
}
return { items, count, total, addItem, removeItem, updateQuantity, clear }
}, { persist: true })
Product Page
<scriptserver
const product = await fetch(`https://api.example.com/products/${params.id}`).then(r => r.json())
useSeoMeta({
title: product.name,
description: product.description,
})
</script>
<scriptclient
const cart = useStore('cart')
const added = state(false)
function addToCart() {
cart.addItem({
id: '{{ product.id }}',
name: '{{ product.name }}',
price: {{ product.price }},
})
added.set(true)
setTimeout(() => added.set(false), 2000)
}
</script>
<h1>{{ product.name }}</h1>
<p>{{ product.description }}</p>
<p>${{ product.price }}</p>
<buttonclick="">
<spanshow="">Add to Cart</span>
<spanshow="">Added</span>
</button>
<p>Cart: <spanx-text=""></span> items</p>
Cart Page
<scriptserver
useSeoMeta({ title: 'Shopping Cart' })
</script>
<scriptclient
const cart = useStore('cart')
</script>
<h1>Shopping Cart</h1>
<divshow="">
<p>Your cart is empty</p>
<buttonclick="">Browse Products</button>
</div>
<divshow="">
<divfor=""key=""class="">
<div>
<px-text=""class=""></p>
<pclass=""><spanx-text=""></span></p>
</div>
<divclass="">
<buttonclick=""></button>
<spanx-text=""></span>
<buttonclick=""></button>
<buttonclick=""class="">Remove</button>
</div>
</div>
<divclass="">
<pclass="">Total: <spanx-text=""></span></p>
<buttonclick=""class="">
Checkout
</button>
</div>
</div>
Stores Survive SPA Navigation
Store state persists across SPA page navigations. When a user navigates from /products to /cart, the cart store retains its items. This is fundamental -- stores are not cleaned up during navigation.
Local state() values, by contrast, are re-created when a page's <script client> re-executes after navigation.
Anti-Patterns
Using
state()for data shared across pages. If two pages need the same data, it belongs in a store. Localstate()is recreated on each page load.
Using
x-datafor state management. Thex-dataattribute (Alpine-style reactivity) is available but not recommended for new code. Use<script client>withstate()and composables instead.x-datadoes not compose well and cannot share state across pages.
Creating a store for page-local state. A modal open/close flag, a form's loading state, or a search input value do not need to be in a store. Use
state()in<script client>.
Importing store files directly instead of using
useStore(). Stores are registered globally by the auto-loader. Always access them viauseStore('name'). Direct imports may create duplicate instances.
Forgetting that
useStore()throws if the store is not found. Double-check that your store file is instoresDirand usesdefineStore(). If the file is namedtypes.tsorindex.ts, it is skipped by the auto-loader.
Quick Reference
| What | Where | API |
|---|---|---|
| Page-local reactive value | <script client> | const x = state(initial) |
| Computed value | <script client> | const x = derived(() => ...) |
| Side effect | <script client> | effect(() => { ... }) |
| Batch updates | <script client> | batch(() => { a.set(1); b.set(2) }) |
| Cross-page state | stores/*.ts | defineStore('name', () => { ... }) |
| Access store | <script client> | const s = useStore('name') |
| Persist store | stores/*.ts | defineStore('name', fn, { persist: true }) |
| Read signal | Anywhere | count() |
| Write signal | Anywhere | count.set(value) |
| Update signal | Anywhere | count.update(fn) |