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()) become computed() instances. They track which state signals they read and recompute only when those change.
  • Actions are wrapped in batch(). The this keyword inside actions is a proxy that reads and writes the underlying signals. Writing this.count++ actually calls countSignal.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.)