Β· English Β· Korean
- π Vue reactivity β Define state and actions with
createStore, and derive computed values withuseSelector. - π§© Composable middleware β Combine logging, persistence and error handling with
compose. - πΎ Persistence and validation β Choose which fields to save, validate restored data and customize merging.
- π‘οΈ Data migration and recovery β Convert saved schemas with
migrate, back up the original record, inspect persistence status and explicitly retry restoration or saving. Failed restoration preserves the original record by default. - π Scoped state sharing β Use
createStoreContextto share stores within a component tree or an SSR request. Providers create fresh stores, and SSR apps are created per request. - π¦ Sync and async error handling β Handle action exceptions and Promise rejections, with a choice to rethrow or handle failures.
- π§βπ» TypeScript support β Typed state, actions, middleware and context arguments, with ESM/CJS entrypoints and type declarations.
- π¦ Vue as the only runtime dependency β Import the core and middleware through separate package entrypoints.
- π§ͺ Automated validation β Unit, regression, SSR and packed-consumer checks cover runtime behavior and TypeScript usage.
pnpm add zenon
# or
npm install zenon
# or
yarn add zenon// stores/counter.ts
import { createStore } from "zenon";
export const useCounter = () =>
createStore((set, get) => ({
count: 0,
increment: () => set({ count: get().count + 1 }, "increment"),
decrement: () => set({ count: get().count - 1 }, "decrement"),
reset: () => set({ count: 0 }, "reset"),
}));<!-- Counter.vue -->
<template>
<div>
<h2>Count: {{ count }}</h2>
<button @click="increment">+1</button>
<button @click="decrement">-1</button>
<button @click="reset">Reset</button>
</div>
</template>
<script setup lang="ts">
import { useCounter } from "./stores/counter";
const store = useCounter();
const count = store.useSelector((s) => s.count); // Partial subscription
const { increment, decrement, reset } = store;
</script>import { createStore, compose } from "zenon";
import { withLogger, withPersist, withErrorBoundary } from "zenon/plugins";
export const useCounter = () =>
createStore(
compose(
withLogger({ store: "counter", timestamp: true }),
withPersist("zenon-counter", {
storage: window.localStorage,
version: 1,
}),
withErrorBoundary({
onError: (error, actionName) => {
console.error(`Error in ${actionName}:`, error);
},
})
)((set, get) => ({
count: 0,
increment: () => set({ count: get().count + 1 }, "increment"),
reset: () => set({ count: 0 }, "reset"),
}))
);Zenon provides three powerful middleware plugins that can be composed together:
Logs all state changes to the console with timestamps.
import { withLogger } from "zenon/plugins";
withLogger({
store: "counter", // Store name for logging
timestamp: true, // Include timestamps (default: false)
expanded: false, // Auto-expand logs (default: false)
});Persists store state to localStorage or sessionStorage.
import { withPersist } from "zenon/plugins";
withPersist("zenon-counter", {
storage: window.localStorage, // default: localStorage
version: 1, // Version for migration control
partialKeys: ["count"], // Persist only specific keys
serialize: JSON.stringify, // Custom serializer
deserialize: JSON.parse, // Custom deserializer
onError: (error) => console.error(error), // Error handler
merge: (persisted, current) => ({ ...current, ...persisted }), // Merge strategy
});Catches and handles errors in store actions.
import { withErrorBoundary } from "zenon/plugins";
withErrorBoundary({
onError: (error, actionName) => {
// Handle error (e.g., send to error tracking service)
console.error(`Error in ${actionName}:`, error);
},
preventRollback: false, // Prevent state rollback on error (default: false)
rethrow: false, // Rethrow error after handling (default: true)
});import { createStore, compose } from "zenon";
import { withLogger, withPersist, withErrorBoundary } from "zenon/plugins";
// Compose style (recommended)
export const useStore = () =>
createStore(
compose(
withLogger({ store: "app" }),
withPersist("app-state"),
withErrorBoundary({ onError: console.error })
)((set, get) => ({
// Your store implementation
}))
);
// Or chain style
export const useStore = () =>
createStore(
withLogger({ store: "app" })(
withPersist("app-state")(
withErrorBoundary({ onError: console.error })((set, get) => ({
// Your store implementation
}))
)
)
);Creates a new store with reactive state management.
function createStore<T extends Record<string, any>>(
initializer: StoreInitializer<T>
): Store<T>;StoreInitializer<T> receives (set, get, api), where api exposes setSilent and subscribe.
Returns: Store<T> with the following methods:
useSelector<U>(selector: (state: T) => U): ComputedRef<U>- Derive a computed value from statesubscribe(listener: Listener<T>): () => void- Subscribe to updates made throughsetsetSilent(patch: Partial<T>)- Update state without triggering subscribersgetState(): T- Get current state snapshot- Plus all store actions defined in your initializer
Returns a Vue computed ref derived from store state. This is separate from the callback-based subscribe API.
const store = useCounter();
const count = store.useSelector((s) => s.count); // Subscribe only to count
const double = store.useSelector((s) => s.count * 2); // Derived values work tooReceive updates made through set with a callback. Direct mutations and setSilent do not notify these listeners.
const unsubscribe = store.subscribe((state, prevState) => {
console.log("State changed:", { state, prevState });
});
// Cleanup
unsubscribe();Update Vue reactive state without notifying manual subscribers or invoking middleware set wrappers, including persistence. This does not provide batching or a transaction.
store.setSilent({ count: 10 }); // Updates state without triggering listenersZenon provides types for state, actions and middleware. Explicit state type arguments help preserve checking across composed initializers.
import { createStore, type Store, type SetFunction, type GetFunction } from "zenon";
// Type your store
type CounterState = {
count: number;
increment: () => void;
decrement: () => void;
};
export const useCounter = (): Store<CounterState> =>
createStore<CounterState>((set, get) => ({
count: 0,
increment: () => set({ count: get().count + 1 }, "increment"),
decrement: () => set({ count: get().count - 1 }, "decrement"),
}));Exported Types:
Store<T>- Store instance typeSetFunction<T>- State setter function typeGetFunction<T>- State getter function typeListener<T>- Subscribe callback typeStoreMiddleware<T>- Middleware function type
The test suite covers core behavior and selected failure cases:
- β Core store functionality (reactivity, subscriptions, selectors)
- β All middleware plugins (logger, persist, error boundary)
- β Error handling and edge cases
- β TypeScript type safety
Run tests:
npm run check # types, tests, build, packed consumer checksContributions are always welcome! Please feel free to open an issue or submit a pull request.
Made with β€οΈ by AGUMON π¦
- The factory examples create a new store per call. For a shared SPA store, create one module-level instance and return it from the composable. In SSR, create a separate instance per request.
- Specify
createStore<State>,compose<State>and plugin type arguments for consistent patch checking; inference through recursive initializer callbacks is not a complete type-safety guarantee. getState()and listenerprevStateare shallow snapshots; nested objects are shared. Replace nested values instead of mutating them in place.setSilentupdates Vue reactivity but bypasses subscriber notifications and middleware set wrappers, including persistence. Direct mutations also bypass these wrappers.- Persistence restores only declared data fields, never actions, unknown keys or prototype-related keys.
partialKeys: []saves and restores no data. - Both
mergestring modes shallowly overlay keys. Supply a callback for nested merging. Version mismatches use defaults unless a synchronousmigratecallback is supplied; failed hydration preserves the original record and disables all writes for that instance. Explicitly setonHydrationError: "overwrite"to discard it. Capture the instance controller throughonReadyand callrehydrate()after repairing storage. - Error boundaries handle synchronous exceptions and Promise rejections.
rethrowdefaults totrue; when false, failed actions may return/resolveundefinedand the return type includesundefined(HandledState<T>). A dynamic boolean is typed conservatively. Composition with value-returning actions may require updating the composed state type. preventRollbackcontrols shallow recovery of a failing wrappedset, not an action-wide transaction or reversal of notifications, persistence, or concurrent async changes. Core patch-failure recovery is separate.composecombines store middleware; use a separate utility for general-purpose function composition.- Reserve
useSelector,subscribe,setSilentandgetStatefor the public API.
validate: (persisted) => boolean checks the filtered persisted data before merging. It is not called for a missing record. Returning false or throwing rejects hydration and preserves the original storage by default. Validation and merge callbacks should be pure.
Propagated set/get failures are reported at the action boundary instead of twice. Errors caught inside the action are not reported. Suppressed set/get failures (rethrow: false) are reported where handled. A failing error callback is logged without replacing the original exception. Nested public actions or multiple error boundaries may report the same propagated failure at their respective boundaries.
Create an instance per SSR request and share it through that app's provide/inject. Concurrent server rendering is tested; automatic client hydration serialization is not provided.
migrate(previous, fromVersion) converts older or unversioned records to the current schema. Future, negative, fractional, and nonnumeric versions are rejected. partialKeys and validate apply after migration, before custom merging. Callbacks must be synchronous and pure.
The original string is backed up at backupKey (default: <key>:zenon:backup) before committing migrated data. Migration storage writes must succeed before updating memory. A different existing backup blocks migration; archive it or choose another backup key. Backups are never automatically overwritten or deleted. Web Storage is not a multi-key or cross-tab transaction; custom storage must preserve previous values when a write throws.
Capture a PersistController with onReady: controller => { persistence = controller; }:
getStatus()returns a nonreactive snapshot:status(ready,error,unavailable),writesBlocked, optionalerror, andbackupKey.rehydrate()explicitly rereads storage, overwriting matching in-memory fields without subscriber notifications. It returns success/failure and can resume writes after repaired hydration.retryPersist()retries writing current memory, but never bypasses blocked hydration. It returns success/failure.
Recovery calls made inside onReady or during another recovery return false. A failed migration/backup or detected concurrent storage change blocks automatic writes even with onHydrationError: "overwrite". Repair the cause and call rehydrate(). An empty-store initial write failure or ordinary update failure can use retryPersist().
import { createStore, createStoreContext } from "zenon";
export const counterContext = createStoreContext((count: number) =>
createStore(() => ({ count })), "Counter"
);
// Per request: create a fresh SSR app, then register a fresh store.
const store = counterContext.provideTo(app, initialCount);
// In a parent's synchronous setup: counterContext.provideStore(initialCount)
// In descendants' synchronous setup: counterContext.useStore()The context holds a unique injection key and the factory, not a global store. Factory arguments and store types are inferred. The factory must synchronously return a fresh instance; returning a singleton defeats request isolation. A new SSR app is required per request.
provideStore returns the new store for the provider component. useStore resolves the nearest ancestor/app provider, not a provider on the same component. Missing providers, use outside component setup, and duplicate registration on the same app/component throw actionable errors. Call component helpers before awaiting. Nested providers affect only their descendants; contexts with identical display names remain independent.
This API does not serialize state into HTML or perform client hydration.
