Stores
Model shared state with explicit actions, precise subscriptions, derived values, and optional persistence.
Keep state and mutations together
createStore accepts an object or an initializer. Prefer an initializer when the store owns actions: callers then express intent through methods instead of coordinating partial state writes throughout the application.
tsimport { createStore } from "@tavojs/core";
export const filters = createStore((set, get) => ({
status: "all",
setStatus(status: string) { set({ status }); },
reset() { set({ status: "all" }); },
isActive() { return get().status !== "all"; }
}));Subscribe to the smallest useful value
Use patch for top-level partial updates, set for a key or nested path, and setState only when replacing the complete state value. subscribeSelector and watch prevent unrelated changes from waking application behavior.
tsconst stop = filters.subscribeSelector(
(state) => state.status,
(nextStatus, previousStatus) => console.log({ nextStatus, previousStatus })
);
filters.set("status", "active");
stop();Choose the correct scope
Use a component model for state owned by one component, a global store for client state shared across routes, and a computed store for a derived value consumed by multiple subscribers.
Use the complete Store contract
Choose replacement, shallow patching, or a focused immutable update deliberately.
tstype Store<State> = {
getState(): State;
setState(next: State | ((previous: State) => State)): State;
set(path, valueOrUpdater): State;
patch(partial: Partial<State> | ((previous: State) => Partial<State>)): State;
subscribe(listener, options?): Unsubscribe;
subscribeSelector(selector, listener, options?): Unsubscribe;
watch(pathOrSelector, listener, options?): Unsubscribe;
};| Method | Use it to |
|---|---|
getState() | Read the current complete snapshot. |
setState(next) | Replace the complete state with a value or updater result. |
patch(partial) | Shallow-merge a top-level partial value or updater result. |
set(path, value) | Immutably update one key or nested path; the updater receives the previous value and whole state. |
subscribe(listener) | Observe every emitted complete-state write. |
subscribeSelector(selector, listener) | Observe a computed selection and ignore equal results. |
watch(target, listener) | Observe a key, nested path, or selector with complete previous-state context. |
tsconst settings = createStore({
theme: "system" as "light" | "dark" | "system",
profile: { name: "Ada", notifications: true }
});
settings.set("theme", "dark");
settings.set("profile.notifications", (enabled) => !enabled);
settings.patch((state) => ({
profile: { ...state.profile, name: "Grace" }
}));
settings.setState({
theme: "system",
profile: { name: "Ada", notifications: true }
});Keep state and actions together
Use initializer closures for named mutations without calling the Store too early.
tsimport { createStore } from "@tavojs/core";
export const cart = createStore((set, get) => ({
items: [] as Array<{ id: string; quantity: number }>,
add(id: string) {
const current = get().items;
set({ items: [...current, { id, quantity: 1 }] });
},
clear() {
set({ items: [] });
}
}));
cart.getState().add("keyboard");In an initializer, set is the Store's top-level patch function and get reads the complete snapshot. Capture them in action methods that run later. Action functions remain part of the Store state at runtime.
Update nested paths immutably
Use strings, numbers, or explicit segment arrays without mutating ancestors.
tsconst board = createStore({
columns: [
{ title: "Todo", cards: [{ id: "a", done: false }] }
]
});
board.set(["columns", 0, "title"], "In progress");
board.set(["columns", 0, "cards", 0, "done"], true);
board.set("columns.0.cards.0.done", (done) => !done);set accepts a top-level key, a dot-separated path, a number, or a readonly segment array.
The updater receives the previous selected value and the complete state.
Tavo.jsclones every ancestor on the path and preserves unrelated references.Missing containers become arrays when the next segment is numeric and objects otherwise.
If the selected value is unchanged under
Object.is,Tavo.jsreturns the original state and emits nothing.A real top-level key containing dots takes precedence over interpreting that string as a path.
Subscribe at the smallest useful level
Choose callback context and equality behavior based on the work being performed.
| API | Target | Listener | Notification |
|---|---|---|---|
subscribe | Complete state | (state, previousState) | Every emitted write |
subscribeSelector | Selector | (selected, previousSelected, state) | Object.is by default |
watch | Key, path, or selector | (selected, previousSelected, state, previousState) | Object.is by default |
tsconst stopName = account.watch(
"user.profile.name",
(name, previousName, state, previousState) => {
console.log({ name, previousName, state, previousState });
},
{ immediate: true }
);
const stopSummary = account.subscribeSelector(
(state) => ({ name: state.user.profile.name, plan: state.plan }),
(summary, previousSummary) => console.log(summary, previousSummary),
{ isEqual: shallowEqual }
);
stopName();
stopSummary();With immediate enabled, Tavo.js invokes the listener at subscription time. Current and previous selected values are the same current value; state and previousState are also the same current snapshot where supplied.
Derive and persist focused state
Build shared projections and save only the client preferences that should survive reloads.
tsimport { computedStore, persistStore } from "@tavojs/core";
const account = createStore({ first: "Ada", last: "Lovelace", token: "secret" });
export const displayName = computedStore(account, (state) => ({
value: `${state.first} ${state.last}`
}));
const stopPersistence = persistStore(account, {
key: "account-preferences",
pick: ({ first, last }) => ({ first, last })
});
// Stop writing future changes when this persistence owner is disposed.
stopPersistence();computedStore
persistStore
persistStorerequires key and accepts storage, serialize, deserialize, and pick overrides.Without browser storage or a supplied storage adapter, it returns a no-op unsubscribe.
It does not save initial state until a
Storewrite emits.Storage and parse errors are not swallowed; handle them in a custom adapter when recovery is required.
computedStorereturns the normalStoreinterface. Treat it as derived output and update its source instead.
Adapt state owned outside Tavo.js
Give browser APIs and third-party stores one consistent snapshot interface.
tsimport { createExternalStore } from "@tavojs/core";
export const colorScheme = createExternalStore({
getSnapshot: () => matchMedia("(prefers-color-scheme: dark)").matches,
getServerSnapshot: () => false,
subscribe(listener) {
const query = matchMedia("(prefers-color-scheme: dark)");
query.addEventListener("change", listener);
return () => query.removeEventListener("change", listener);
}
});
class ThemeController extends TavoController {
onMount() {
return this.listenExternal(
colorScheme,
(dark) => this.model.patch({ dark }),
{ immediate: true }
);
}
}An ExternalStore provides getSnapshot, subscribe, and an optional getServerSnapshot. createExternalStore returns that interface unchanged. Controller listenExternal reads getSnapshot, suppresses equal values with Object.is by default, and manages unsubscription.
Share named client state across routes
Define global stores once and resolve them directly or through controllers.
tsimport {
defineGlobalStore,
getGlobalStore,
hasGlobalStore,
listGlobalStores
} from "@tavojs/core";
export const preferences = defineGlobalStore("preferences", (set) => ({
density: "comfortable" as "comfortable" | "compact",
setDensity(density: "comfortable" | "compact") {
set({ density });
}
}));
class ToolbarController extends TavoController {
compact() {
this.stores.get<ReturnType<typeof preferences.getState>>("preferences")
.getState()
.setDensity("compact");
}
}defineGlobalStorecreates a name once. A later definition returns the existingStoreand ignores the new initial state.getGlobalStorethrows for an unknown name;hasGlobalStorechecks first andlistGlobalStoresreturns registered names.Controller
this.storesexposes get, has, and list but does not define stores.Use the
@tavojs/corepackage root for the complete global-store API.
Understand automatic Store hydration
Keep request data isolated while the framework carries Store state across SSR.
Tavo.js serializes written Store state during SSR, embeds it in the page, and hydrates matching client Stores. Top-level function values are excluded from the serialized snapshot. This lifecycle is automatic; snapshot scopes and document-state hydration are framework internals, not application APIs.
Avoid common Store mistakes
Mutating nested state
Replacing state with patch
Object selectors without equality
Forgetting unsubscribe
Calling initializer helpers immediately
Putting request data in a global Store
Look up exact public types
Follow linked API names to their canonical TypeScript declarations and package boundaries.