Store Overview
Udodi Store is a reactive key/value state system for application state that lives outside individual components.
Components should keep state that belongs to a single component local to that component. The Store is for state that has a broader ownership boundary: theme and locale, session information, feature flags, shared UI state, drafts, or other client-side data that multiple parts of an application need to read and update consistently.
The Store uses Udodi's fine-grained reactivity system. A store read can therefore become a dependency of an effect, computed value, or template. A store write invalidates the reactive entry for that key, allowing only the consumers that depend on that key to react.
The Store is deliberately smaller in scope than a server-state system. It owns application state; Query Pool owns the lifecycle of asynchronous data.
The Store Model
At its core, the Store is a single reactive key/value map, and every store key has one current value.
The important operations are:
- Read with
get(). - Replace with
set(). - Derive and replace with
update(). - Notify after an in-place mutation with
touch(). - Remove with
delete().
The public Store API exposes the global store and registered modules. Module APIs provide scoped access to the same underlying store by prefixing their keys and actions.
For example:
theme
auth:user
cart:items
ui:sidebarOpenare all entries in the same global store. A module such as auth or cart simply provides a structured way to work with its corresponding prefix.
This architecture gives the Store one consistent reactivity model, one batching mechanism, and one persistence mechanism while still allowing applications to organize state by feature.
What the Store Provides
| Capability | What it provides |
|---|---|
| Reactive state | get(), set(), update(), touch(), and delete() over reactive keys |
| Actions | Named state operations invoked as fn(ctx, payload) |
| Selectors | Lazy computed values derived from store state |
| Subscriptions | Imperative callbacks for changes to a specific key |
| Batching | Defers store notifications until a batch's outermost scope completes |
| Modules | Feature-scoped state, actions, selectors, lifecycle, and a reactive state proxy |
| Persistence | Optional IndexedDB synchronization for selected keys |
The Store API itself is synchronous. get(), set(), update(), delete(), and related state operations do not require asynchronous access.
Persistence is the exception only at the storage boundary: IndexedDB hydration and writes are asynchronous, but they do not change the normal synchronous Store API.
Choosing a State Boundary
The most important Store decision is not which method to call. It is deciding who owns the state.
Component state
Keep state local when it belongs to one component:
const Dialog = createComponent({
state() {
return {
open: false,
};
},
});A component-local value does not need to become application state merely because another component could theoretically access it.
Global Store
Use the global store for shared application values:
import { store } from "udodi";
store.set("theme", "dark");
store.set("locale", "en");This is the smallest and most direct Store layer.
Module
Use defineStore() when a feature deserves a formal boundary:
defineStore("cart", {
state: {
items: [],
total: 0,
},
actions: {
addItem(ctx, item) {
ctx.update("items", (items) => [
...items,
item,
]);
},
},
});Modules add registration, retrieval, lifecycle cleanup, initial state, and a module-scoped reactive state proxy.
Global Store
The global store is the simplest entry point:
import { store } from "udodi";
store.set("theme", "dark");
const theme = store.get("theme");A global store key is not tied to a component or module.
Reading
const theme = store.get("theme");A get() call participates in dependency tracking when it executes inside an active reactive computation.
For example:
const label = computed(() => {
return store.get("theme") === "dark"
? "Dark mode"
: "Light mode";
});The computed value now depends on the theme key.
Writing
store.set("theme", "light");set() replaces the value stored under the key. If the new value is equal to the current value, the write is treated as unchanged and does not notify dependents.
Updating
Use update() when the next value depends on the current value:
store.update(
"count",
(count) => (count ?? 0) + 1,
);This is preferable to manually reading and then writing when the operation is conceptually a state transition.
Deleting
store.delete("draft");Deleting a key removes its value and also stops persistence associated with that key.
If the distinction between missing and present with undefined matters, use has() rather than relying on get():
if (store.has("draft")) {
// The key exists.
}Reactivity
Store reactivity follows the same fine-grained dependency model used elsewhere in Udodi.
get() tracks
A store read establishes a dependency when it happens inside an effect, computed value, or template evaluation:
const total = computed(() => {
return (
store.get("price") *
store.get("quantity")
);
});The computation depends on price and quantity, not on unrelated store keys.
set() and update() notify
Replacing a value invalidates the key when the new value differs from the previous value according to Object.is():
store.set("count", 1);
store.set("count", 1); // unchanged
store.set("count", 2); // changedIn-place mutation requires touch()
The Store tracks changes at the key/root-value level. Arrays, Maps, and Sets stored under a key are wrapped so that structural mutations (such as push, splice, set, add, delete, or clear) notify dependents automatically.
Nested object mutations are not tracked. If you change a property on an object that lives inside a reactive collection, call touch() after the mutation:
const items = store.get("items");
items[0].name = "Notebook";
store.touch("items");The same applies when the root value itself is a plain object:
const user = store.get("user");
user.name = "Jane";
store.touch("user");This tells the Store to notify dependents of the key without replacing the stored object.
Structural collection changes do not need touch():
const items = store.get("items");
items.push({
id: 2,
name: "Book",
});
// dependents of "items" are notified automaticallyUse replacement when practical:
store.set("items", [
...store.get("items"),
{
id: 2,
name: "Book",
},
]);Use touch() when you mutate nested object data in place. The same rule applies through module APIs and action contexts.
Batching
batch() coordinates multiple Store writes:
batch(() => {
store.set("firstName", "Attamah");
store.set("lastName", "Lovelace");
store.set("role", "Mathematician");
});Writes made inside a batch are staged rather than committed immediately. When the outermost batch completes, the staged changes are committed.
Batches can be nested:
batch(() => {
store.set("a", 1);
batch(() => {
store.set("b", 2);
store.set("c", 3);
});
store.set("d", 4);
});The inner batch does not independently commit the staged changes; the outermost batch remains responsible for the final commit.
Batching is primarily about coordinating store writes and deferring their notifications. It should not be interpreted as a promise that every reactive consumer runs exactly once regardless of how many distinct keys it depends on. The normal reactive update rules still apply when the staged keys are committed.
Modules
A module is a registered, feature-scoped store created with defineStore().
import {
defineStore,
useStore,
destroyStore,
} from "udodi";
defineStore("cart", {
state: {
items: [],
total: 0,
},
actions: {
addItem(ctx, item) {
ctx.update("items", (items) => [
...items,
item,
]);
},
},
});The module's state is stored under its namespace:
cart:items
cart:totalIts actions are also namespaced internally:
cart:addItemRetrieve the registered module from anywhere:
const cart = useStore("cart");
await cart.dispatch("addItem", {
id: 1,
name: "Book",
});Notice that module consumers work with local keys and action names. The module API applies its namespace internally.
Why use a module?
A module is useful when a feature needs more than a simple shared key:
- initial state,
- named actions,
- a module-scoped reactive
stateproxy, - selectors and subscriptions,
- persistence,
- an explicit cleanup hook,
- registration and retrieval through the registry.
Modules provide a lifecycle boundary around feature state.
Module identity
A registered module has one registry identity per name:
const first = defineStore("cart", {
state: {
items: [],
},
});
const second = useStore("cart");
console.log(first === second); // trueThe registry therefore prevents different parts of the application from accidentally creating separate module instances under the same name.
Destroying a module
A module can be destroyed through either the registry or its own API:
destroyStore("cart");or:
cart.destroy();Destruction is the module lifecycle boundary. It removes the module's registered state and actions and performs the module's configured cleanup.
If the module owns persistence or other reactive resources, destruction also tears down those module-owned resources.
See Store Registry for the complete module lifecycle.
The Action Model
Actions are named operations over Store state.
Register a global action:
store.defineAction(
"increment",
(ctx, by = 1) => {
ctx.update(
"count",
(count) => (count ?? 0) + by,
);
},
);Execute it:
const result = await store.dispatch(
"increment",
2,
);The handler is always invoked as:
fn(ctx, payload)The payload is optional and application-defined.
Actions can be synchronous:
store.defineAction("reset", (ctx) => {
ctx.set("count", 0);
});or asynchronous:
store.defineAction(
"save",
async (ctx, data) => {
const result = await api.save(data);
ctx.set("saved", result);
return result;
},
);dispatch() returns the action's result. When the handler is asynchronous, the returned value is a Promise.
Action context
Global and module actions receive the same context shape:
{
state,
get,
set,
update,
touch,
select,
}The difference is the scope.
For a global action:
ctx → global storeFor a module action:
ctx → module namespaceThis lets action code operate against its supplied context instead of depending on the global Store directly.
Selectors
Selectors are for derived state.
const doubleCount = store.select(
(state) =>
(state.get("count") ?? 0) * 2,
);A selector is backed by Udodi's computed primitive. It is lazy and tracks the store reads performed by its selector function.
For example:
const cartTotal = cart.select((state) => {
return state.items.reduce(
(total, item) =>
total + item.price * item.quantity,
0,
);
});The selector depends on the reactive state it reads. When those dependencies change, the computed value becomes stale according to normal computed semantics.
Selectors are useful for avoiding duplicated state:
source state
│
├── items
├── prices
└── quantities
│
▼
selector
│
▼
totalStore the source data; derive values that can be calculated from it.
Subscriptions
Subscriptions provide an imperative observation mechanism for a specific key:
const stop = store.subscribe(
"theme",
(next, prev) => {
console.log("theme:", prev, "→", next);
},
);The callback receives:
(next, prev)and the subscription returns an unsubscribe function:
stop();Subscriptions are useful when a state change must trigger an imperative side effect.
For reactive UI derivation, prefer a normal effect, computed value, selector, or template dependency. A subscription should not be treated as a replacement for the reactive dependency system.
Persistence
Store persistence is opt-in and uses IndexedDB.
const controller = store.persist(
["theme", "locale"],
{
debounce: 50,
},
);
await controller.ready;Persistence adds a storage layer around selected Store keys. It does not make the Store itself asynchronous:
store.set("theme", "dark");
const theme = store.get("theme");The state operation remains synchronous even though persistence happens asynchronously.
Hydration
With hydration enabled, persisted values are restored from IndexedDB before the persistence controller begins subscribing to the selected keys.
This ordering matters:
IndexedDB
│
│ hydrate
▼
Store state
│
│ subscribe
▼
Future changes → IndexedDBIt prevents the initial persistence subscription from immediately writing the in-memory initial value back over the persisted value.
When startup depends on restored state, wait for:
await controller.ready;Persistence controller
The controller exposes four important lifecycle operations:
readyflush()clear()stop()
| Method | Purpose |
|---|---|
ready | Resolves when persistence initialization and hydration have completed. |
flush() | Immediately writes pending persistence changes. |
clear() | Removes persisted values for the controller's configured keys. |
stop() | Stops future synchronization while leaving already persisted data in IndexedDB. |
clear() and stop() therefore have different purposes:
clear() → remove persisted data
stop() → stop syncing, keep persisted dataDebouncing
Persistence can be debounced:
store.persist(["draft"], {
debounce: 250,
});This delays persistence writes until the debounce interval expires.
A debounce value of 0 uses microtask scheduling rather than a timer.
Use flush() when pending changes must be written immediately.
Module persistence
Modules can persist their own local keys:
const settings = defineStore("settings", {
state: {
theme: "system",
locale: "en",
},
});
settings.persist([
"theme",
"locale",
]);The module API uses local keys while the underlying Store entries are scoped by the module:
settings:theme
settings:localeThis keeps persistence scoped to the module and prevents different modules from accidentally sharing the same Store key.
See Persistent Stores for the complete persistence API and controller lifecycle.
Store vs Component State vs Query Pool
These systems solve different ownership problems.
| Concern | Prefer |
|---|---|
| UI state owned by one component | Component state |
| Shared client/application state | Store |
| Server data and request lifecycle | Query Pool |
| Remote-data caching and invalidation | Query Pool |
| Background refresh or asynchronous query work | Query Pool |
| Local preferences and durable client state | Store + persistence |
The distinction is primarily about state ownership.
A Store value is application-owned client state:
- theme
- locale
- sidebar state
- feature flags
- drafts
- session/client state
Query Pool data is request-owned or server-owned state:
- API responses
- query cache
- loading/error state
- refresh lifecycle
- mutations
- worker-backed asynchronous work
A Store action may still call an API. That does not turn the Store into a query cache:
store.defineAction(
"renameUser",
async (ctx, name) => {
const user = await api.renameUser(name);
ctx.set("user", user);
},
);This can be perfectly valid when the resulting value is application state. But if the problem requires request caching, deduplication, invalidation, refresh, cancellation, or mutation lifecycle management, Query Pool is the appropriate abstraction.
A Practical Decision Tree
When deciding where a value belongs, ask:
Does the value belong only to one component?
│
yes
▼
Component state
no
│
▼
Is it client/application state?
│
yes
▼
Store
│
├── Simple shared keys → global store
│
├── Feature lifecycle → module
no
│
▼
Is it asynchronous/server-owned data?
│
yes
▼
Query PoolThis boundary keeps the Store focused instead of turning it into a general-purpose replacement for every other state system.
Store Architecture
The Store can be understood as a layered API over one reactive state space:
┌───────────────────┐
│ Global Store │
│ reactive map │
└─────────┬─────────┘
│
┌──────────────┴──────────────┐
│ │
▼ ▼
global modules
│ │
│ registry / scope
│ │
└──────────────┬──────────────┘
│
actions / selectors
subscriptions
batching
│
▼
persistence
│
▼
IndexedDBThe registry therefore organizes state; it does not replace the global reactive storage model.
Likewise, persistence is an adapter around Store state; it does not become a second source of truth. The in-memory Store remains the synchronous application-facing state layer.
Core Principles
The Store is easiest to use correctly when these principles are kept in mind:
State has an owner: Use component state for component-owned concerns and Store for application-owned concerns.
Modules provide feature boundaries: Use a module when a feature needs initial state, scoped actions, lifecycle management, or a reusable state API.
Reads establish dependencies: Store reads inside effects, computed values, and templates establish reactive dependencies. Do not manually synchronize consumers that can depend on the Store reactively.
Replace or touch: When state changes, either replace the root value with
set()/update()or explicitly notify withtouch()after an in-place mutation.Derive instead of duplicate: Use selectors for values that can be calculated from existing Store state.
Persist deliberately: Persistence is opt-in. Persist only the state that genuinely needs to survive a page reload.
Keep server state in Query Pool: Do not turn Store actions into a home-grown query cache when the problem is fundamentally asynchronous server data.
For the conceptual foundation of the reactive primitives used by Store, see Reactivity.