Creating Stores
This guide covers the global Store API, including reactive state, actions, batching, selectors, and subscriptions.
Importing the Store
import {
store,
batch,
} from "udodi";store— the global reactive key/value store.batch— groups multiple writes into one coherent reactive update.
For registered feature modules, use defineStore, useStore, and destroyStore instead. See Store Registry.
Reading and Writing State
The global Store is a reactive key/value map. Each key holds one value and participates independently in Udodi's fine-grained reactivity system.
get
store.get("theme");get(key):
- Returns the current value for
key. - Creates a reactive entry with value
undefinedif the key has never been set. - Tracks the key when called inside an effect, computed value, or template.
- Causes that reactive consumer to re-run when the key subsequently changes.
For example:
const theme = computed(() => {
return store.get("theme") ?? "system";
});The computed value now depends specifically on the theme key.
set
store.set("theme", "dark");
store.set("count", 0);set(key, value):
- Stores
valueunderkey. - Does nothing when
valueis equal to the current value. - Notifies reactive dependents and subscriptions when the value changes.
store.set("count", 1);
store.set("count", 1); // no update
store.set("count", 2); // updateupdate
Use update() when the next value depends on the current value:
store.update(
"count",
(count) => (count ?? 0) + 1,
);The updater receives the current value and its return value becomes the new value.
Conceptually:
store.set(
"count",
fn(store.get("count")),
);Using update() is preferable when expressing read-modify-write operations because the operation stays within the Store API.
touch
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. Use touch() when you change a property on an object that lives inside a reactive collection, or when the root value itself is a plain object mutated in place:
const items = store.get("items");
items[0].name = "Notebook";
store.touch("items");const user = store.get("user");
user.name = "Jane";
store.touch("user");touch(key):
- Notifies reactive dependents of the key.
- Notifies subscriptions.
- Returns
falsewhen the key does not exist. - Returns
truewhen the key exists and the notification is triggered.
Structural collection changes do not need touch():
const items = store.get("items");
items.push({
id: 1,
name: "Book",
});
// dependents of "items" are notified automaticallyPrefer replacing the value with set() when practical:
store.set(
"items",
[...store.get("items"), newItem],
);Use touch() when you mutate nested object data in place and need to preserve the existing object or array reference.
has, keys, and delete
Check whether a key exists:
store.has("theme");Get all registered keys:
store.keys();Remove a key:
store.delete("theme");Deleting a key removes its reactive entry and also stops persistence associated with that key.
clear
store.clear();clear() removes all global Store state and registered global actions.
It also stops active persistence and clears pending batch state.
Use clear() when the entire global Store should be reset rather than when removing an individual key.
Batching Updates
Use batch() when several state changes should be committed as one coherent update:
import {
store,
batch,
} from "udodi";
batch(() => {
store.set("count", 10);
store.set("theme", "light");
store.delete("draft");
});Batch behavior
Writes and deletes inside a batch are staged until the outermost batch completes.
batch()
│
├── set()
├── set()
├── delete()
│
▼
outermost batch finishes
│
▼
commit staged changes
│
▼
reactive notificationNested batches are supported:
batch(() => {
store.set("a", 1);
batch(() => {
store.set("b", 2);
store.set("c", 3);
});
store.set("d", 4);
});Only the outermost batch commits the staged changes.
Reads inside a batch see the staged values through the normal get() path, so code does not need a separate "pending state" API.
Batching changes when updates become observable; it does not introduce a different state API.
Actions
Actions provide named operations over Store state.
defineAction
Register an action on the global Store:
store.defineAction(
"increment",
(ctx, by = 1) => {
ctx.update(
"count",
(count) => (count ?? 0) + by,
);
},
);Actions receive:
(ctx, payload)The payload is optional and can be any value:
store.defineAction(
"setTheme",
(ctx, theme) => {
ctx.set("theme", theme);
},
);Actions may also be asynchronous:
store.defineAction(
"loadUser",
async (ctx, id) => {
const user = await api.getUser(id);
ctx.set("user", user);
return user;
},
);The same action name can be registered again. The latest registration replaces the previous handler.
Action Context
Every global action receives the same ActionContext:
{
state, // reactive proxy over the global Store
get, // (key) => value
set, // (key, value) => void
update, // (key, fn) => void
touch, // (key) => boolean
select, // (selector, scope?) => computed
}ctx.state
ctx.state is a reactive proxy over the global Store.
Property access maps to Store operations:
store.defineAction(
"setTheme",
(ctx, theme) => {
ctx.state.theme = theme;
},
);Conceptually:
ctx.state.theme
// equivalent to ctx.get("theme")
ctx.state.theme = "dark"
// equivalent to ctx.set("theme", "dark")Use ctx.get(), ctx.set(), and ctx.update() when explicit Store operations make the intent clearer.
Action context and modules
Global actions receive a context over the global Store.
Module actions use the same ActionContext shape, but their context is scoped to the module's state. This means module code can work with local keys without knowing the module's internal namespace.
For example, see Store Registry.
dispatch
Run a registered action with dispatch():
await store.dispatch(
"increment",
2,
);For an asynchronous action:
const user = await store.dispatch(
"loadUser",
userId,
);dispatch() returns the action handler's result directly.
If the handler is asynchronous, the returned value is a Promise.
Missing actions
By default, dispatching an action that does not exist warns and returns undefined:
store.dispatch("doesNotExist");
// warns
// returns undefinedUse throwOnMissing when a missing action should be treated as an error:
store.dispatch(
"doesNotExist",
null,
{
throwOnMissing: true,
},
);strict: true can be used as an equivalent strict option:
store.dispatch(
"doesNotExist",
null,
{
strict: true,
},
);hasAction and deleteAction
Check whether an action is registered:
store.hasAction("increment");Remove an action:
store.deleteAction("increment");Deleting an action does not modify Store state. It only removes the named action handler.
Selectors
Selectors derive values from Store state.
const doubleCount = store.select(
(state) => {
return (state.get("count") ?? 0) * 2;
},
);Read the derived value by invoking the returned computed:
doubleCount();Selectors are built on Udodi's computed primitive.
They are:
- lazy — computation begins when the selector is read;
- reactive — dependencies are tracked through Store reads;
- cached — the computed value is reused until a tracked dependency changes.
For example:
store.set("count", 10);
doubleCount();
// 20
store.set("count", 20);
doubleCount();
// 40Selectors inside actions
The action context exposes the same selector mechanism:
store.defineAction(
"logDouble",
(ctx) => {
const double = ctx.select(
(state) =>
(state.get("count") ?? 0) * 2,
);
console.log(double());
},
);The optional second argument to select() is a scope used when the selector needs lifecycle cleanup, particularly in module contexts.
Global selectors generally do not need to provide a scope explicitly.
Subscriptions
Use subscribe() when imperative code needs to react to a Store key changing:
const stop = store.subscribe(
"count",
(next, prev) => {
console.log(
"count changed:", prev, "→", next
);
},
);The returned function removes the subscription:
stop();Subscription behavior
Subscriptions are implemented using Udodi's reactive effect system.
On the initial run:
- if the value is not
undefined, the callback runs with(next, undefined); - if the value is
undefined, no initial callback is emitted.
Subsequent changes invoke:
cb(next, prev);A subscription can also run when touch() forces a notification even though the stored reference remains the same.
For example, nested object mutation still needs an explicit touch():
const items = store.get("items");
const stop = store.subscribe(
"items",
(next, prev) => {
// next and prev may be
// the same object reference.
},
);
items[0].name = "Notebook";
store.touch("items");Structural collection mutations notify without touch():
items.push(newItem);
// subscription for "items" runs automaticallyUse subscriptions primarily for imperative integration:
store.subscribe(
"theme",
(theme) => {
document.documentElement
.dataset.theme = theme;
},
);For UI rendering, prefer normal reactive reads in components and templates.
Organizing State
The global Store is appropriate for simple shared state:
store.set("theme", "system");
store.set("locale", "en");When state belongs to a feature and needs a formal boundary, use a Store module:
import {
defineStore,
useStore,
} from "udodi";
defineStore("cart", {
state: {
items: [],
},
actions: {
addItem(ctx, item) {
ctx.update(
"items",
(items) => [
...items,
item,
],
);
},
},
});
const cart = useStore("cart");
cart.dispatch(
"addItem",
{
id: 1,
name: "Book",
},
);Modules provide scoped state and actions without requiring application code to manually manage key prefixes.
Internally, module state is namespaced, but that namespace is an implementation detail. Application code should work with the module's local keys:
cart.get("items");
cart.set("items", nextItems);
cart.update("items", fn);
cart.dispatch("addItem", item);See Store Registry for the complete module API.
Putting It Together
The following example combines global state, actions, batching, selectors, and subscriptions:
import {
store,
batch,
} from "udodi";
// Shared application state
store.set("theme", "system");
store.set("count", 0);
// Named operation
store.defineAction(
"setTheme",
(ctx, theme) => {
ctx.set("theme", theme);
},
);
store.defineAction(
"increment",
(ctx, by = 1) => {
ctx.update(
"count",
(count) => (count ?? 0) + by,
);
},
);
// Coherent multi-key update
batch(() => {
store.dispatch(
"setTheme",
"dark",
);
store.dispatch(
"increment",
1,
);
});
// Derived state
const isDark = store.select(
(state) =>
state.get("theme") === "dark",
);
console.log(isDark());
// true
// Imperative integration
const stop = store.subscribe(
"theme",
(theme) => {
document.documentElement
.dataset.theme = theme;
},
);The global Store remains deliberately small:
Global Store
├── reactive state
├── actions
├── batching
├── selectors
└── subscriptionsFor structured feature state, move to Store modules rather than manually creating namespaces.
Persistence
Persistence is intentionally documented separately because it introduces an asynchronous storage boundary while the Store itself remains synchronous.
const controller = store.persist(
["theme", "locale"],
{
debounce: 50,
},
);
await controller.ready;See Persistent Stores for:
- IndexedDB persistence;
- hydration;
- debounced writes;
ready;flush();clear();stop();- persistence errors;
- module persistence.