Skip to content
ljlm0402Public

About

πŸ¦‰ A minimal, reactive state manager for Vue 3, inspired by Zustand

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation


Zenon Logo

Zenon

πŸ¦‰ A minimalist, Zustand-inspired state manager for Vue 3

npm Info

npm Version Release Version npm Downloads License

GitHub Stars GitHub Forks Contributors Issues

Β· English Β· Korean


✨ Features

  • πŸƒ Vue reactivity β€” Define state and actions with createStore, and derive computed values with useSelector.
  • 🧩 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 createStoreContext to 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.

πŸ’Ύ Installation

pnpm add zenon
# or
npm install zenon
# or
yarn add zenon

πŸ“ Quick Start

Basic Usage

// 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>

With Middleware

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"),
    }))
  );

🧩 Middleware Plugins

Zenon provides three powerful middleware plugins that can be composed together:

withLogger

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)
});

withPersist

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
});

withErrorBoundary

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)
});

Composing Multiple Plugins

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
        }))
      )
    )
  );

πŸ“š API Reference

createStore

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 state
  • subscribe(listener: Listener<T>): () => void - Subscribe to updates made through set
  • setSilent(patch: Partial<T>) - Update state without triggering subscribers
  • getState(): T - Get current state snapshot
  • Plus all store actions defined in your initializer

useSelector

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 too

subscribe

Receive 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();

setSilent

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 listeners

πŸ— TypeScript Support

Zenon 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 type
  • SetFunction<T> - State setter function type
  • GetFunction<T> - State getter function type
  • Listener<T> - Subscribe callback type
  • StoreMiddleware<T> - Middleware function type

πŸ§ͺ Testing

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 checks

🀝 Contributing

Contributions are always welcome! Please feel free to open an issue or submit a pull request.

πŸ’³ License

MIT


Made with ❀️ by AGUMON πŸ¦–

Behavioral boundaries

  • 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 listener prevState are shallow snapshots; nested objects are shared. Replace nested values instead of mutating them in place.
  • setSilent updates 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 merge string modes shallowly overlay keys. Supply a callback for nested merging. Version mismatches use defaults unless a synchronous migrate callback is supplied; failed hydration preserves the original record and disables all writes for that instance. Explicitly set onHydrationError: "overwrite" to discard it. Capture the instance controller through onReady and call rehydrate() after repairing storage.
  • Error boundaries handle synchronous exceptions and Promise rejections. rethrow defaults to true; when false, failed actions may return/resolve undefined and the return type includes undefined (HandledState<T>). A dynamic boolean is typed conservatively. Composition with value-returning actions may require updating the composed state type.
  • preventRollback controls shallow recovery of a failing wrapped set, not an action-wide transaction or reversal of notifications, persistence, or concurrent async changes. Core patch-failure recovery is separate.
  • compose combines store middleware; use a separate utility for general-purpose function composition.
  • Reserve useSelector, subscribe, setSilent and getState for the public API.

Hydration validation and failure ownership

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.

Migrations and recovery

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, optional error, and backupKey.
  • 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().

Request- and component-scoped stores

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.

About

πŸ¦‰ A minimal, reactive state manager for Vue 3, inspired by Zustand

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages