Stores
Global state management with reactive stores.
What is a Store?
A store is a self-contained unit of state, computed values, and actions. Instead of scattering signals across your codebase, you define everything in one place and use it from any component. Think of it as Zustand meets signals: define a store once, use it everywhere.
import { createStore, derived } from 'what-framework';
const useCounter = createStore({
// State: becomes signals automatically
count: 0,
// Computed: uses derived() to declare
doubled: derived(state => state.count * 2),
// Actions: methods that mutate state
increment() { this.count++; },
decrement() { this.count--; },
});
createStore returns a hook function. Call it in any component to get reactive access to state, computed values, and actions:
function Counter() {
const store = useCounter();
return h('div', null,
h('p', null, 'Count: ', () => store.count),
h('p', null, 'Doubled: ', () => store.doubled),
h('button', { onClick: store.increment }, '+'),
h('button', { onClick: store.decrement }, '-'),
);
}
State Properties
Any non-function value in the store definition becomes a signal automatically. Numbers, strings, booleans, arrays, and objects all work:
const useSettings = createStore({
// All of these become signals
theme: 'dark', // string
fontSize: 16, // number
sidebarOpen: true, // boolean
tags: ['js', 'what'], // array
user: { name: 'Alice' }, // object
toggleSidebar() {
this.sidebarOpen = !this.sidebarOpen;
},
});
When you read a state property from the store object returned by the hook, you get the current value directly. No need to call it as a function. The getter on the returned object calls the underlying signal for you.
Read store properties inside a thunk
Because they are getters rather than accessors, the read happens exactly where you write it. Put it inside a () => ... (or an effect, or a computed) so it lands in a reactive position, otherwise you get a snapshot that never updates.
const store = useCounter();
// LIVE - the read happens inside a reactive thunk
h('p', null, () => store.count)
// FROZEN - reads once, while building the element
h('p', null, store.count)
// FROZEN - destructuring is a read too
const { count } = useCounter();
Also note store.count is a value, not a function: calling it as store.count() throws. Actions are ordinary properties and are safe to destructure.
Derived Values
Use derived(fn) to declare computed properties that automatically update when the state they depend on changes:
import { createStore, derived } from 'what-framework';
const useCart = createStore({
items: [],
// Recomputes when items changes
totalItems: derived(state => state.items.length),
// Can depend on other state
totalPrice: derived(state =>
state.items.reduce((sum, item) => sum + item.price * item.qty, 0)
),
// Can format derived data
formattedTotal: derived(state =>
`$${state.items.reduce((s, i) => s + i.price * i.qty, 0).toFixed(2)}`
),
addItem(item) {
this.items = [...this.items, item];
},
});
The state parameter in the derived callback is a proxy that reads the current value of other store signals. Derived values are backed by computed() under the hood, so they are lazy and cached: they do not recompute until something they depend on actually changes.
Actions
Any function in the store definition that is not wrapped with derived() becomes an action. Inside actions, this is a proxy that lets you read and write state properties directly:
const useTodos = createStore({
items: [],
filter: 'all',
// Read state with this.items, write with this.items = ...
addTodo(text) {
this.items = [...this.items, {
id: Date.now(),
text,
done: false,
}];
},
toggleTodo(id) {
this.items = this.items.map(item =>
item.id === id ? { ...item, done: !item.done } : item
);
},
setFilter(filter) {
this.filter = filter;
},
// Actions can call other actions
clearCompleted() {
this.items = this.items.filter(item => !item.done);
},
});
All mutations inside a single action are automatically batched. If an action updates three signals, subscribers run once after the action completes, not once per write.
Using Stores in Components
The hook returned by createStore can be called in any component. Every component that calls the same hook shares the same underlying state:
function TodoList() {
const todos = useTodos();
return h('div', null,
h('h2', null, () => `${todos.items.length} items`),
h('ul', null,
() => todos.items.map(item =>
h('li', {
onClick: () => todos.toggleTodo(item.id),
style: { textDecoration: item.done ? 'line-through' : 'none' },
}, item.text)
)
),
);
}
function AddTodo() {
const todos = useTodos();
return h('form', {
onSubmit: (e) => {
e.preventDefault();
const input = e.target.elements.text;
todos.addTodo(input.value);
input.value = '';
},
},
h('input', { name: 'text', placeholder: 'New todo...' }),
h('button', { type: 'submit' }, 'Add'),
);
}
Both TodoList and AddTodo share the same store instance. When AddTodo calls addTodo(), the list in TodoList updates automatically.
Keys need the compiler
There is deliberately no key on the <li> above. Keyed reconciliation is a compiler feature: what-compiler lowers a keyed .map() (and <For>) to mapArray. On the buildless h() path a key prop is ignored outright, so passing one only suggests a guarantee you do not have. Expect every row to be rebuilt when the list changes, which means lost focus, closed <details>, and restarted CSS transitions. Use the compiler if you need stable rows. See Control Flow.
Multiple Stores
Organize your application state by feature. Each store is independent and focused on a single concern:
// stores/auth.js
export const useAuth = createStore({
user: null,
token: null,
isLoggedIn: derived(state => state.user !== null),
login(user, token) {
this.user = user;
this.token = token;
},
logout() {
this.user = null;
this.token = null;
},
});
// stores/cart.js
export const useCart = createStore({
items: [],
totalPrice: derived(state =>
state.items.reduce((s, i) => s + i.price * i.qty, 0)
),
addItem(product) { /* ... */ },
removeItem(id) { /* ... */ },
});
// stores/ui.js
export const useUI = createStore({
theme: 'light',
sidebarOpen: false,
modal: null,
toggleTheme() {
this.theme = this.theme === 'light' ? 'dark' : 'light';
},
openModal(name) { this.modal = name; },
closeModal() { this.modal = null; },
});
Stores vs Context
Use stores for global, application-wide state that many unrelated components need (auth, cart, theme). Use context for scoped state that only a subtree of components needs (form state, layout config). Stores are singletons; context can have multiple instances in different parts of the tree.
Stores are per process, not per request
createStore builds its signals once, when the module is evaluated. In the browser that is exactly what you want: one instance for the whole tab. On a server it means one instance for every concurrent request, so a store holding user-specific data (the useAuth example above is the worst case) can leak one visitor's state into another visitor's HTML.
Keep module-scope stores for client-only state. For per-request data, use the loader's data via useLoaderData() or a context provider created inside the render, both of which are scoped to one render.
How It Works
Under the hood, createStore does three things:
- State values are converted into
signal()instances. Reading them creates reactive subscriptions. - Derived values (wrapped with
derived()) becomecomputed()instances. They track which state signals they read and recompute only when those change. - Actions are wrapped in
batch(). Thethiskeyword inside actions is a proxy that reads and writes the underlying signals. Writingthis.count++actually callscountSignal.set(countSignal.peek() + 1).
The hook function returned by createStore builds a fresh object of getters each time it is called, and all of them read the same underlying signals. This means every component gets its own getter bindings while sharing the same reactive state. (Real Proxy objects do appear inside a store, but only for the state argument handed to derived() and for this inside an action. The object the hook returns is a plain one.)