mvc-kit
v4.5.2
Published
MVVM application framework: a tiny zero-dependency reactive core with opt-in forms, UI helpers, storage adapters, and bindings for React, Solid, and web components
Readme
mvc-kit

An MVVM application framework: a tiny zero-dependency reactive core plus opt-in app-layer entries — forms, UI helpers, storage adapters, headless components — and thin bindings for React, Solid, and web components over one framework-agnostic contract.
- Tiny core — the
mvc-kitentry is ~14 concepts of pure reactive state management; everything else is opt-in - Zero dependencies — React and Solid are optional peers; storage backends are injected by you
- Framework-agnostic — the same ViewModel binds to React, Solid, and custom elements unchanged
- TypeScript-first — typed state, typed events, typed async tracking
- Pluggable — adapter-based storage, plugin-based observability, a documented binding contract for new frameworks
Installation
npm install mvc-kitQuick Start (60 seconds)
Write a ViewModel once. State is the source of truth, derived values are getters, async tracking and method binding are automatic:
import { ViewModel } from 'mvc-kit';
interface CounterState {
count: number;
}
class CounterViewModel extends ViewModel<CounterState> {
get double(): number {
return this.state.count * 2;
}
increment() {
this.set({ count: this.state.count + 1 });
}
}Bind it in React:
import { useLocal } from 'mvc-kit/react';
function Counter() {
const vm = useLocal(CounterViewModel, { count: 0 });
return (
<button onClick={vm.increment}>
{vm.state.count} (doubled: {vm.double})
</button>
);
}The same class in Solid — no changes to the ViewModel:
import { createLocal } from 'mvc-kit/solid';
function Counter() {
const vm = createLocal(() => new CounterViewModel({ count: 0 }));
return (
<button onClick={vm.increment}>
{vm.state.count} (doubled: {vm.double})
</button>
);
}And the same class as a web component — no framework at all:
import { ViewModelElement } from 'mvc-kit/element';
class CounterElement extends ViewModelElement<CounterViewModel> {
protected createInstance() {
return new CounterViewModel({ count: 0 });
}
protected render(vm: CounterViewModel) {
this.textContent = `${vm.state.count} (doubled: ${vm.double})`;
}
connectedCallback() {
super.connectedCallback();
this.onclick = () => this.vm.increment();
}
}
customElements.define('x-counter', CounterElement);This is the point of mvc-kit: your application logic — state, derivation, async orchestration, validation — lives in plain TypeScript classes that know nothing about the UI framework. The bindings are thin: construct → subscribe → init() → dispose(). That contract is public and documented, so the community can add frameworks (docs/extending.md).
Entry-Point Map
The core entry is the actual "reactive state management" library. Everything else is an opt-in app-layer bundle — import it only when you need it; tree-shaking and preserveModules keep unused entries out of your bundle.
| Entry | What's in it | Concepts | When you need it |
|-------|--------------|----------|------------------|
| mvc-kit | ViewModel, Model, Collection, PersistentCollection (+ StorageAdapter contract), Resource, Service, EventBus, Channel, Controller, Trackable, singleton registry (+ SSR dehydrate/hydrate), installPlugin, error utilities | ~14 | Always — this is the framework-agnostic core |
| mvc-kit/react | useLocal, useSingleton, useInstance, useModel, useFormModel, useField, useEvent, useTeardown, Provider/useResolve | 9 | React apps (hooks only — no components) |
| mvc-kit/react/ui | DataTable, CardList, InfiniteScroll | 3 | Headless, unstyled table/list/scroll components |
| mvc-kit/forms | FormModel, attemptSubmitAll | 2 | Interactive forms (touched gating, submit reveal, server errors) |
| mvc-kit/helpers | Sorting, Pagination, Selection, Feed, Pending, ValueResource, PersistentValue | 7 | Composable UI-state helpers for tables, feeds, retry queues, single-value records, one persisted record |
| mvc-kit/offline-kit | createOfflineKit, Outbox, SyncController, SyncStatusViewModel, Connectivity | 5 | Offline-first apps — a durable mutation queue (queue offline, sync on reconnect, retry/backoff); a write-side outbox, not a sync engine |
| mvc-kit/storage | localStorageAdapter, sessionStorageAdapter, indexedDBAdapter, asyncStorageAdapter | 4 | Persisting a PersistentCollection to browser/device storage |
| mvc-kit/solid | createLocal, createSingleton, reactive | 3 | Solid apps |
| mvc-kit/element | ViewModelElement | 1 | Web components / no framework |
| mvc-kit/testing | flushAsync, waitFor, waitForAsync, seedSingletons (+ re-exports of singleton, hasSingleton, teardownAll, provideSingleton, dehydrate, hydrate) | 4 | Test suites |
| mvc-kit/devtools | loggerPlugin, inspectRegistry, attachDevtools | 3 | Debugging and observability |
Core Classes & Roles
| Class | Role | Entry | Scope |
|-------|------|-------|-------|
| ViewModel<S, E?> | Reactive state + computed getters + async tracking + typed events | mvc-kit | Component-scoped (useLocal / createLocal) |
| Model<S> | Generic editable entity: validation + dirty tracking + commit/rollback | mvc-kit | Component-scoped (useModel) |
| FormModel<S> | Model + form-presentation state (touched gating, submit reveal, server errors, per-field validating) | mvc-kit/forms | Component-scoped (useFormModel + useField) |
| Collection<T> | Reactive typed array, shared data cache, optimistic updates, eviction & TTL | mvc-kit | Singleton |
| PersistentCollection<T> | Collection + storage persistence via an injected StorageAdapter | mvc-kit (adapters in mvc-kit/storage) | Singleton |
| Resource<T> | Collection + async tracking, transparent Collection injection | mvc-kit | Singleton |
| Service | Stateless infrastructure adapter (HTTP, storage) | mvc-kit | Singleton |
| EventBus<E> | Typed pub/sub for cross-cutting events | mvc-kit | Singleton |
| Channel<M> | Persistent connection (WebSocket/SSE) with auto-reconnect | mvc-kit | Singleton |
| Controller | Stateless multi-ViewModel orchestrator (rare) | mvc-kit | Component-scoped |
| Trackable | Base class for custom reactive objects — subscribable + disposable + auto-bind | mvc-kit | ViewModel property / useLocal |
| Sorting<T> | Multi-column sort state + apply pipeline | mvc-kit/helpers | ViewModel property |
| Pagination | Page/pageSize state + array slicing | mvc-kit/helpers | ViewModel property |
| Selection<K> | Key-based selection set with toggle/select-all | mvc-kit/helpers | ViewModel property |
| Feed<T> | Cursor + hasMore + item accumulation for server-side pagination | mvc-kit/helpers | ViewModel property |
| Pending<K, Meta?> | Per-item operation queue with retry + status tracking | mvc-kit/helpers | Resource property |
| ValueResource<T> | Single-value async record — "Resource, but scalar" (one value + async tracking + lifecycle) | mvc-kit/helpers | Singleton / ViewModel property |
| PersistentValue<T> | Single persisted record — "PersistentCollection, but scalar" (one value + storage, always defaulted) | mvc-kit/helpers | Singleton / ViewModel property |
Interfaces
| Interface | Description |
|-----------|-------------|
| Subscribable<S> | Has state, subscribe(), dispose(), disposeSignal |
| Disposable | Has disposed, disposeSignal, dispose() |
| Initializable | Has initialized, init() |
| StorageAdapter<T> | load(key) / save(key, items) / remove(key) — sync or async |
| MvcKitPlugin | Named bag of optional observability hooks (onCreate, onStateChange, onAsync, …) |
| TaskState | { loading: boolean; error: string \| null; errorCode: AppError['code'] \| null; cause: unknown } |
| Listener<S> | (state: S, prev: S) => void |
| Updater<S> | (prev: S) => Partial<S> \| void |
| ValidationErrors<S> | Partial<Record<keyof S, string>> |
| AsyncMap<T> | Maps each async method key of T to its TaskState (the shape of vm.async) |
Singleton Registry & SSR
| Function | Description |
|----------|-------------|
| singleton(Class, ...args) | Get or create singleton (uses static DEFAULT_STATE when present and no args given) |
| provideSingleton(Class, instance) | Seed the registry with a specific instance (test injection) |
| hasSingleton(Class) | Check if a live singleton exists |
| teardown(Class) / teardownAll() | Dispose and remove singleton(s) |
| dehydrate() | Serialize singleton data on the server (instances opt in via toJSON(); payload keyed by static SSR_KEY, falling back to class name) |
| hydrate(data) | Seed singleton state on the client from the server payload |
onInit() data loading runs on the client only — server data crosses the boundary via dehydrate()/hydrate(). See BEST_PRACTICES.md → SSR.
Error Utilities
| Export | Description |
|--------|-------------|
| AppError (type) | Canonical error shape; AppError['code'] is the closed classified-code union |
| HttpError | Typed HTTP error class for services to throw |
| isAbortError(error) | Guard for AbortError — use in catch blocks with shared-state side effects |
| classifyError(error) | Maps raw errors → AppError |
React Integration
import { useInstance, useLocal, useSingleton } from 'mvc-kit/react';| Hook | Description |
|------|-------------|
| useLocal(Class \| factory, ...args, deps?) | Component-scoped instance, auto-init/dispose, optional deps array to recreate |
| useSingleton(Class, ...args) | Singleton resolution with auto-init and shared state |
| useInstance(subscribable) | Subscribe to an existing instance (no lifecycle management) |
| useModel(factory) | Model binding: { state, errors, valid, dirty, model } |
| useFormModel(factory) | FormModel binding: adds gated visibleErrors + submitAttempted |
| useField(model, key) | Single FormModel field subscription with surgical re-renders |
| useEvent(source, event, handler) | Subscribe to an EventBus or ViewModel event |
| useResolve(Class, ...args) | Resolve from Provider context or fall back to singleton() |
| useTeardown(...Classes) | Teardown singletons on unmount |
Headless components (DataTable, CardList, InfiniteScroll) live in mvc-kit/react/ui — the hooks entry stays component-free.
DI & Testing — Provider and useResolve
useResolve(Class) resolves from the nearest Provider context, falling back to singleton() when none is present — so components stay injectable in tests and stories (wrap them in <Provider provide={[[ApiService, mockApi]]}>) without any test-only code. Full reference: Provider & useResolve.
See the DI mechanisms decision table for when to use singleton() vs Provider vs provideSingleton.
Documentation
- BEST_PRACTICES.md — the authoritative patterns guide: rules, rationale, decision tables, quick-reference checklist
- MIGRATION.md — v3 → v4 migration guide (import paths, removed APIs, new capabilities)
- docs/extending.md — how to write a StorageAdapter, a plugin, or a framework binding
- DESIGN.md — the v4 architecture contract
Every class and hook keeps a colocated reference doc next to its source:
Core (mvc-kit)
| Doc | Description |
|-----|-------------|
| ViewModel | State, computed getters, async tracking, typed events, lifecycle hooks |
| Model | Validation, dirty tracking, commit/rollback for editable entities |
| Collection | Reactive typed array, CRUD, optimistic updates, eviction & TTL |
| PersistentCollection | Adapter-based storage persistence: hydration, write-behind, error policy |
| Resource | Collection + async tracking with external Collection injection |
| Controller | Stateless orchestrator for multi-ViewModel coordination |
| Service | Non-reactive infrastructure adapters (HTTP, storage, SDKs) |
| EventBus | Typed pub/sub for cross-cutting events |
| Channel | Persistent connections (WebSocket, SSE) with auto-reconnect |
| Trackable | Base class for custom reactive objects |
| Singleton Registry | singleton(), provideSingleton(), teardown(), SSR dehydrate()/hydrate() |
| Plugins | MvcKitPlugin hooks and installPlugin() |
App layer
| Doc | Description |
|-----|-------------|
| FormModel | Touched gating, submit reveal, server errors, validating flags |
| Sorting · Pagination · Selection · Feed · Pending · ValueResource · PersistentValue | Composable UI-state helpers |
| offline-kit | Durable offline-first mutation queue: createOfflineKit typed binder, Outbox, SyncController, SyncStatusViewModel, Connectivity |
React (mvc-kit/react, mvc-kit/react/ui)
| Doc | Description |
|-----|-------------|
| useLocal · useInstance · useSingleton | Reactive state bindings |
| useModel · useFormModel & useField | Model/FormModel bindings |
| useEvent · useTeardown | Events and lifecycle utilities |
| Provider & useResolve | DI seam: context-provided instances with singleton() fallback |
| SSR | Server rendering: getServerSnapshot, client-only onInit, data transfer |
| DataTable · CardList · InfiniteScroll | Headless components |
Other bindings
| Doc | Description | |-----|-------------| | createLocal · createSingleton · reactive | Solid binding | | ViewModelElement | Web components binding |
Storage, testing, devtools
| Doc | Description |
|-----|-------------|
| Storage adapters | localStorageAdapter, sessionStorageAdapter, indexedDBAdapter, asyncStorageAdapter |
| Testing kit | flushAsync, waitFor, waitForAsync, seedSingletons + the blessed unit pattern |
| Devtools | loggerPlugin, inspectRegistry, attachDevtools |
Examples
examples/ holds two apps: a todo app — state, derivation, async, optimistic updates all in a shared TodosViewModel — bound to three shells, and an offline-first app (a durable mutation queue: offline send, sync-on-reconnect, retry/backoff; React shell, React Native-portable). From the package root:
npm run dev:react # http://localhost:3000
npm run dev:solid # http://localhost:3001
npm run dev:element # http://localhost:3002Dev Mode (__MVC_KIT_DEV__)
mvc-kit ships development-only safety checks (set-in-getter detection, ghost async ops, duplicate storage keys, reserved-key guards) behind the __MVC_KIT_DEV__ flag. It defaults to false — no bundler config required. Define it as true in your dev build (e.g. Vite define) to enable the checks; production builds dead-code-eliminate them.
License
MIT
