State Management
AkashJS includes a built-in store system powered by signals. No external state management library needed — defineStore() gives you shared state, derived getters, and actions out of the box.
Defining a Store
A store has three parts: state (reactive values), getters (derived computations), and actions (methods that mutate state).
import { defineStore } from '@akashjs/runtime';
const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
name: 'My Counter',
}),
getters: {
doubled: (state) => state.count() * 2,
label: (state) => `${state.name()}: ${state.count()}`,
},
actions: {
increment() {
this.count.update((c) => c + 1);
},
decrement() {
this.count.update((c) => c - 1);
},
setName(name: string) {
this.name.set(name);
},
},
});defineStore() returns a composable function. Calling it always returns the same singleton instance — state is shared across your entire app.
Using Stores in Components
Call the composable inside your component to get the store. State properties are signals, getters are computed signals, and actions are plain functions.
import { defineComponent } from '@akashjs/runtime';
const Counter = defineComponent((ctx) => {
const counter = useCounterStore();
return () => (
<div>
<p>{counter.label()}</p>
<button on:click={counter.increment}>+</button>
<button on:click={counter.decrement}>-</button>
</div>
);
});Because state properties are signals, reads inside the render function automatically track dependencies. Only the DOM nodes that depend on changed state will update.
State
The state option is a factory function that returns your initial values. Each property becomes a Signal on the store instance.
const store = useCounterStore();
// Read state (call the signal)
store.count(); // 0
store.name(); // 'My Counter'
// Write state
store.count.set(10);
store.name.set('Updated');
// Update from previous value
store.count.update((c) => c + 1);Getters
Getters are computed signals derived from state. They receive the signalified state object and return a derived value.
const useTodoStore = defineStore('todos', {
state: () => ({
todos: [] as { text: string; done: boolean }[],
}),
getters: {
completed: (state) => state.todos().filter((t) => t.done),
remaining: (state) => state.todos().filter((t) => !t.done),
count: (state) => state.todos().length,
},
actions: {
add(text: string) {
this.todos.update((list) => [...list, { text, done: false }]);
},
},
});Getters recompute lazily — only when their dependencies change and something reads them.
Actions
Actions are methods that mutate state. Inside an action, this refers to the store's state signals.
const useAuthStore = defineStore('auth', {
state: () => ({
user: null as { name: string } | null,
token: '',
}),
getters: {
isLoggedIn: (state) => state.user() !== null,
},
actions: {
login(user: { name: string }, token: string) {
this.user.set(user);
this.token.set(token);
},
logout() {
this.user.set(null);
this.token.set('');
},
},
});$reset
Reset all state back to its initial values:
const store = useCounterStore();
store.count.set(99);
store.count(); // 99
store.$reset();
store.count(); // 0
store.name(); // 'My Counter'This calls the state() factory again and sets every signal to the fresh value.
$patch
Merge partial state into the store. Only the specified keys are updated — other state is untouched:
const store = useCounterStore();
store.$patch({ count: 10, name: 'Updated' });
store.count(); // 10
store.name(); // 'Updated'$patch calls .set() on each signal internally, so all updates are batched into a single flush.
$snapshot
Get a plain JavaScript object of the current state (no signals, just values):
const store = useCounterStore();
store.count.set(5);
const snap = store.$snapshot();
// { count: 5, name: 'My Counter' }
// Useful for logging, serialization, or sending to an API
console.log(JSON.stringify(snap));$subscribe
Listen to all state changes in a store. The callback fires only when state actually changes — not on initial subscription:
const store = useCounterStore();
const unsubscribe = store.$subscribe((state) => {
console.log('State changed:', state);
});
// (no log yet — callback is not called on subscribe)
store.count.set(1);
// Logs: State changed: { count: 1, name: 'My Counter' }
store.increment();
// Logs: State changed: { count: 2, name: 'My Counter' }
// Stop listening
unsubscribe();The internal effect is automatically disposed when all subscribers unsubscribe.
Multiple Stores
Split your state into domain-specific stores. Each store is independent and identified by its string ID.
const useUserStore = defineStore('user', {
state: () => ({ name: '', email: '' }),
actions: {
setUser(name: string, email: string) {
this.name.set(name);
this.email.set(email);
},
},
});
const useCartStore = defineStore('cart', {
state: () => ({
items: [] as { id: string; qty: number }[],
}),
getters: {
totalItems: (state) => state.items().reduce((sum, i) => sum + i.qty, 0),
},
actions: {
addItem(id: string) {
this.items.update((list) => {
const existing = list.find((i) => i.id === id);
if (existing) {
return list.map((i) => (i.id === id ? { ...i, qty: i.qty + 1 } : i));
}
return [...list, { id, qty: 1 }];
});
},
},
});Use them together in a component:
const Checkout = defineComponent((ctx) => {
const user = useUserStore();
const cart = useCartStore();
return () => (
<div>
<p>Hi {user.name()}, you have {cart.totalItems()} items.</p>
</div>
);
});Testing Stores
Stores are singletons, so you need to clear them between tests to avoid shared state. Use clearStores() from @akashjs/runtime.
import { describe, it, expect, beforeEach } from 'vitest';
import { clearStores } from '@akashjs/runtime';
beforeEach(() => {
clearStores();
});
it('starts at zero', () => {
const store = useCounterStore();
expect(store.count()).toBe(0);
});
it('increments', () => {
const store = useCounterStore();
store.increment();
expect(store.count()).toBe(1);
expect(store.doubled()).toBe(2);
});
it('resets', () => {
const store = useCounterStore();
store.count.set(50);
store.$reset();
expect(store.count()).toBe(0);
});
it('snapshots', () => {
const store = useCounterStore();
store.count.set(7);
expect(store.$snapshot()).toEqual({ count: 7, name: 'My Counter' });
});Store Plugins
Extend all stores with shared behavior using plugins. A plugin is an object with init and/or onAction hooks.
import { configureStores } from '@akashjs/runtime';
configureStores({
plugins: [
{
init(store) {
// Called when a store is first created
console.log(`Store "${store.$id}" initialized`);
},
onAction(store, name, args) {
console.log(`Action "${name}" called on "${store.$id}" with`, args);
},
},
],
});Plugin interface:
| Hook | Description |
|---|---|
init(store) | Called once when the store is first instantiated. Use it to add properties, subscribe, or set up side effects. |
onAction(store, name, args) | Called before every action executes. Receives the store instance, action name, and arguments array. |
Persistence
Stores can automatically persist state to localStorage or sessionStorage using the persist option.
Basic persistence
Set persist: true to save all state to localStorage under the store's ID:
const useSettingsStore = defineStore('settings', {
state: () => ({
theme: 'light',
locale: 'en',
}),
persist: true,
});State is hydrated from storage when the store is first created. If stored data exists, it overwrites the factory defaults.
Selective persistence
Pick which keys to persist, change the storage key, or use sessionStorage:
const useAuthStore = defineStore('auth', {
state: () => ({
user: null as { name: string } | null,
token: '',
loginAttempts: 0,
}),
persist: {
pick: ['user', 'token'],
key: 'my-auth',
storage: 'sessionStorage',
},
});Only user and token are saved. loginAttempts is always fresh on reload.
Multiple targets
Persist different keys to different storage backends:
const useAppStore = defineStore('app', {
state: () => ({
preferences: { theme: 'dark' },
session: { tabId: '' },
}),
persist: [
{ pick: ['preferences'], storage: 'localStorage' },
{ pick: ['session'], storage: 'sessionStorage' },
],
});Cross-tab sync
Persisted stores automatically listen to storage events. When another tab writes to the same key, the store updates in real time — no extra configuration needed.
Real-time Sync
The sync option connects a store to other clients over a real-time transport, enabling collaborative and multiplayer state.
Basic setup
import { defineStore } from '@akashjs/runtime';
import { createWebSocketTransport } from '@akashjs/runtime';
const wsTransport = createWebSocketTransport('wss://sync.example.com');
const useTodoStore = defineStore('todos', {
state: () => ({
items: [] as { text: string; done: boolean }[],
}),
actions: {
add(text: string) {
this.items.update((list) => [...list, { text, done: false }]);
},
toggle(index: number) {
this.items.update((list) =>
list.map((t, i) => (i === index ? { ...t, done: !t.done } : t)),
);
},
},
sync: {
transport: wsTransport,
room: 'project-42',
presence: true,
},
});All mutations are broadcast to peers in the same room. Incoming changes merge into local state automatically.
Presence and peer info
When presence: true is set, the store exposes sync metadata through $sync:
const store = useTodoStore();
store.$sync.connected(); // true | false
store.$sync.peers(); // ['peer-a', 'peer-b']
store.$sync.presence; // signal — your own presence data
store.$sync.peerPresence(); // Map { 'peer-a' => { cursor: 3 }, ... }
// Set your own presence
store.$sync.presence.set({ cursor: 5 });Offline + sync
Combine persist and sync for offline-first apps. State saves locally so the app works without a connection, then syncs when the transport reconnects:
const useDocStore = defineStore('doc', {
state: () => ({ blocks: [] as string[] }),
persist: true,
sync: {
transport: createWebSocketTransport('wss://sync.example.com'),
room: 'doc-1',
},
});TypeScript
Actions have full this typing out of the box. Inside any action, this is typed as the union of state signals, getters, and other actions — powered by ThisType. No manual annotations needed:
const useStore = defineStore('example', {
state: () => ({ count: 0, label: '' }),
getters: {
summary: (state) => `${state.label()}: ${state.count()}`,
},
actions: {
reset() {
this.count.set(0); // Signal<number>
this.label.set(''); // Signal<string>
this.summary(); // string (getter)
this.bump(); // other action
},
bump() {
this.count.update((c) => c + 1);
},
},
});