Utilities
Store (@reckona/mreact-store)
@reckona/mreact-store provides global and shared reactive state primitives for Mreact. Use it for application-wide UI state or domain state that should be updated through explicit actions, selectors, subscriptions, transactions, or persistence.
Source: packages/store on GitHub.
Install
pnpm add @reckona/mreact-storeWhen to use it
Use @reckona/mreact-store for state owned by your app: selected workspace, open panels, draft UI state, local preferences, optimistic client state, or domain state that multiple components need to read.
Use @reckona/mreact-query for server state: data fetched from APIs, request dedupe, cache invalidation, hydration, and mutations. Use form-specific helpers for form state and validation.
Basic store
import { createStore, shallowEqual } from "@reckona/mreact-store";
const counter = createStore({ count: 0, label: "counter" });
const count = counter.select((state) => state.count);
const labelSnapshot = counter.select((state) => ({ label: state.label }), shallowEqual);
counter.set((state) => ({ count: state.count + 1 }));store.select() returns a selected cell. Use dispose() for selectors created outside a framework cleanup lifecycle.
Actions and transactions
Keep write operations behind small functions so callers do not repeat update logic. Use transaction() when several updates should notify subscribers once.
const workspace = createStore({
activeProjectId: "docs",
sidebarOpen: true,
selectedIds: [] as string[],
});
export function selectProject(projectId: string) {
workspace.transaction(() => {
workspace.set({ activeProjectId: projectId });
workspace.set({ selectedIds: [] });
});
}Selectors only notify when their selected value changes. Use shallowEqual for object-shaped selector results that should not notify when the object fields are equal.
Request-isolated stores
Never put per-request server state in a module-level browser-style singleton. Use createRequestStoreFactory() when server code needs a fresh store per request.
import { createRequestStoreFactory } from "@reckona/mreact-store";
const createRequestStore = createRequestStoreFactory(() => ({
userId: undefined as string | undefined,
flash: undefined as string | undefined,
}));
export function requestStore() {
return createRequestStore();
}This keeps request-isolated state separate from process-level state in long-lived Node, container, Cloudflare, or Lambda runtimes.
Persistence
The persist option connects store state to a storage adapter. Use it for non-secret browser preferences such as theme, density, or last-opened panel. Do not persist secrets, access tokens, authorization data, or server-only state in browser storage.
For simple write-only adapters, pass a callback. The callback runs after each committed change and receives the next store state.
const preferences = createStore(
{ density: "comfortable", theme: "system" },
{
persist(state) {
localStorage.setItem("preferences", JSON.stringify(state));
},
},
);Use the descriptor form when the store should hydrate from storage, migrate older versions, or serialize asynchronous writes in commit order. load() runs after store creation, and a hydrated state does not immediately save itself back to storage. Persisted records must use persistedStoreState(state, version) so ordinary application state with state or version fields remains unambiguous. When the saved version differs from the configured version, migrate(state, savedVersion) can normalize the loaded state before it replaces the current value.
interface Preferences {
density: "compact" | "comfortable";
theme: "dark" | "light" | "system";
}
import { createStore, persistedStoreState } from "@reckona/mreact-store";
const preferences = createStore<Preferences>(
{ density: "comfortable", theme: "system" },
{
persist: {
load() {
const text = localStorage.getItem("preferences");
return text === null
? undefined
: JSON.parse(text) as ReturnType<typeof persistedStoreState<Preferences>>;
},
migrate(state, savedVersion) {
return savedVersion === 1
? { ...state, density: state.density ?? "comfortable" }
: state;
},
save(state) {
localStorage.setItem(
"preferences",
JSON.stringify(persistedStoreState(state, 2)),
);
},
version: 2,
},
},
);Await preferences.persistence.ready before depending on hydrated values. persistence.status is "hydrating", "ready", or "error"; persistence.error identifies whether loading, migration, or saving failed. By default, a local commit made during hydration wins over the loaded value. Set hydrationConflict to "replace", "merge", or a resolver only when that policy matches the application’s data model. Older untagged { state, version } records require the explicit acceptLegacyPersistedState: true migration opt-in.
Hydration lifecycle and failures
persistence.ready always settles after the initial load and optional migration have finished, including when either operation fails. It does not reject. Check persistence.status.get() and persistence.error.get() after awaiting it when startup logic must distinguish a usable hydrated value from a load or migrate failure. A later save failure also changes the status to "error" with phase "save"; the ordered save queue continues, so a subsequent state change can still reach storage.
await preferences.persistence.ready;
const failure = preferences.persistence.error.get();
if (failure !== undefined) {
reportPersistenceFailure(failure.phase, failure.error);
}Hydration is not a write barrier. Calls to set(), replace(), or transaction() take effect and queue their normal save immediately, even while load() is pending. The default "preserve-local" conflict policy prevents the eventual loaded value from overwriting such a commit. Use "replace" only when storage is authoritative, "merge" for a shallow loaded/current merge, or a resolver for a domain-specific policy.
Notifications, instrumentation, and devtools
When hydration applies a loaded value, store subscribers receive one replacement notification and the optional instrument callback receives a replace event with the previous and hydrated states. The devtools hook receives the matching store:replace event. Hydration does not immediately call save() for that loaded replacement, which avoids writing an unchanged record back to storage during startup. A local commit made before or after hydration uses the ordinary notification, instrumentation, devtools, and save lifecycle.
const events: string[] = [];
const preferences = createStore(initialPreferences, {
instrument(event) {
events.push(event.type);
},
persist: {
load: loadPreferences,
save: savePreferences,
},
});
const unsubscribe = preferences.subscribe((state, previous) => {
renderPreferenceChange(previous, state);
});
await preferences.persistence.ready;
unsubscribe();The persistence status and error cells are separate from store state subscriptions. Read or observe those cells when the UI needs a hydration or storage-health indicator; use store.subscribe() for application-state changes.
Outside the framework runtime
Use store.subscribe() for integration code that lives outside a component lifecycle, such as devtools, adapters, or imperative widgets. Dispose the subscription when the integration shuts down.
API surface
createStore()store.get()store.set()store.replace()store.select()store.subscribe()store.transaction()createRequestStoreFactory()shallowEqual()
API reference: these links open the generated TypeDoc pages inside this docs site.