mvc-kit
v2.14.0
Published
Zero-magic, class-based reactive ViewModel library
Readme
mvc-kit
Zero-
- Tiny: ~2KB min+gzip (core), ~7KB with React
- Zero dependencies
- Framework-agnostic core
- TypeScript-first
Installation
npm install mvc-kitAI Agent Plugin
mvc-kit ships with built-in context for AI coding assistants — stays in sync with your installed version automatically.
npx mvc-kit-setup # Set up all agents
npx mvc-kit-setup claude # Claude Code only
npx mvc-kit-setup cursor # Cursor only
npx mvc-kit-setup copilot # GitHub Copilot onlyClaude Code — installs .claude/rules/, .claude/commands/, and .claude/agents/. Auto-updates on subsequent npm install/npm update.
Cursor — writes .cursorrules. Copilot — writes .github/copilot-instructions.md. Both use idempotent markers, safe to re-run.
Quick Start
import { ViewModel } from 'mvc-kit';
interface CounterState {
count: number;
}
class CounterViewModel extends ViewModel<CounterState> {
increment() {
this.set({ count: this.state.count + 1 });
}
}
const counter = new CounterViewModel({ count: 0 });
counter.subscribe((state, prev) => console.log(state.count));
counter.increment(); // logs: 1Core Classes
ViewModel
Reactive state container. Extend and call set() to update state.
class TodosViewModel extends ViewModel<{ items: string[] }> {
addItem(item: string) {
this.set(prev => ({ items: [...prev.items, item] }));
}
// Called once after init() — use for subscriptions, data fetching, etc.
protected onInit() {
this.loadItems();
}
// Called after every state change
protected onSet(prev: Readonly<{ items: string[] }>, next: Readonly<{ items: string[] }>) {
console.log('Items changed:', prev.items.length, '→', next.items.length);
}
protected onDispose() {
// Cleanup logic
}
// After init(), async methods are automatically tracked:
// vm.async.loadItems → { loading: boolean, error: string | null }
async loadItems() {
// this.disposeSignal is automatically aborted on dispose — fetch() will throw AbortError
const res = await fetch('/api/items', { signal: this.disposeSignal });
const items = await res.json();
this.set({ items });
}
}Model
Reactive entity with validation and dirty tracking.
class UserModel extends Model<{ name: string; email: string }> {
setName(name: string) {
this.set({ name });
}
protected validate(state: { name: string; email: string }) {
const errors: Partial<Record<keyof typeof state, string>> = {};
if (!state.name) errors.name = 'Name is required';
if (!state.email.includes('@')) errors.email = 'Invalid email';
return errors;
}
}
const user = new UserModel({ name: '', email: '' });
console.log(user.valid); // false
console.log(user.errors); // { name: 'Name is required', email: 'Invalid email' }
user.setName('John');
console.log(user.dirty); // true (differs from committed state)
user.commit(); // Mark current state as baseline
user.rollback(); // Revert to committed stateCollection
Reactive typed array with CRUD and query methods.
interface Todo {
id: string;
text: string;
done: boolean;
}
const todos = new Collection<Todo>();
// CRUD (triggers re-renders)
todos.add({ id: '1', text: 'Learn mvc-kit', done: false });
todos.upsert({ id: '1', text: 'Updated', done: true }); // Add-or-replace by ID
todos.update('1', { done: true });
todos.remove('1');
todos.reset([...]); // Replace all
todos.clear();
// Optimistic update with rollback
const rollback = todos.optimistic(() => {
todos.update('1', { done: true });
});
// On failure: rollback() restores pre-update state
// Eviction & TTL (opt-in via static overrides)
class RecentMessages extends Collection<Message> {
static MAX_SIZE = 500; // FIFO eviction when exceeded
static TTL = 5 * 60_000; // Auto-expire after 5 minutes
}
// Properties
todos.items; // T[] (same as state)
todos.length; // number of items
// Query (pure, no notifications)
todos.get('1'); // Get by id (O(1) via internal index)
todos.has('1'); // Check existence
todos.find(t => t.done); // Find first match
todos.filter(t => !t.done);
todos.sorted((a, b) => a.text.localeCompare(b.text));
todos.map(t => t.text); // Map to new arrayPersistent Collections
Collections that cache/repopulate from browser or device storage. Three adapters for different environments:
// Web — localStorage (auto-hydrates on first access)
import { WebStorageCollection } from 'mvc-kit/web';
class CartCollection extends WebStorageCollection<CartItem> {
protected readonly storageKey = 'cart';
}
// Web — IndexedDB (per-item storage, requires hydrate())
import { IndexedDBCollection } from 'mvc-kit/web';
class MessagesCollection extends IndexedDBCollection<Message> {
protected readonly storageKey = 'messages';
}
// React Native — configurable backend (requires hydrate())
import { NativeCollection } from 'mvc-kit/react-native';
NativeCollection.configure({
getItem: (key) => AsyncStorage.getItem(key),
setItem: (key, value) => AsyncStorage.setItem(key, value),
removeItem: (key) => AsyncStorage.removeItem(key),
});
class TodosCollection extends NativeCollection<Todo> {
protected readonly storageKey = 'todos';
}All adapters inherit Collection's full API (CRUD, query, optimistic updates, eviction, TTL). Mutations are automatically persisted via debounced writes. See src/PersistentCollection.md for details.
Resource
Collection + async tracking toolkit. Extends Collection with lifecycle and automatic async method tracking.
class UsersResource extends Resource<User> {
private api = singleton(UserService);
async loadAll() {
const data = await this.api.getAll(this.disposeSignal);
this.reset(data);
}
async loadById(id: number) {
const user = await this.api.getById(id, this.disposeSignal);
this.upsert(user);
}
}
const users = singleton(UsersResource);
await users.init();
users.loadAll();
users.async.loadAll.loading; // true while loading
users.async.loadAll.error; // error message, or null
users.async.loadAll.errorCode; // 'not_found', 'network', etc.
// Inherits all Collection methods
users.items; // User[]
users.get(1); // User | undefined
users.filter(u => u.active);Supports external Collection injection for shared data scenarios:
class UsersResource extends Resource<User> {
constructor() {
super(singleton(SharedUsersCollection)); // All mutations go to the shared collection
}
}Controller
Stateless orchestrator for complex logic. Component-scoped, auto-disposed.
class CheckoutController extends Controller {
constructor(
private cart: CartViewModel,
private api: ApiService
) {
super();
}
// Called once after init() — set up subscriptions, wire dependencies
protected onInit() {
// subscribeTo registers auto-cleanup — no manual tracking needed
this.subscribeTo(this.cart, () => this.onCartChanged());
}
async submit() {
const items = this.cart.state.items;
// this.disposeSignal auto-cancels the request if the controller is disposed mid-flight
await this.api.checkout(items, { signal: this.disposeSignal });
this.cart.clear();
}
}Service
Non-reactive infrastructure service. Singleton-scoped.
class ApiService extends Service {
async fetchUser(id: string) {
// this.disposeSignal auto-cancels if the service is disposed
const res = await fetch(`/api/users/${id}`, { signal: this.disposeSignal });
return res.json();
}
}EventBus
Typed pub/sub event bus.
interface AppEvents {
'user:login': { userId: string };
'user:logout': void;
'notification': { message: string };
}
const bus = new EventBus<AppEvents>();
// Subscribe
const unsubscribe = bus.on('user:login', ({ userId }) => {
console.log(`User ${userId} logged in`);
});
// One-time subscription
bus.once('notification', ({ message }) => alert(message));
// Emit
bus.emit('user:login', { userId: '123' });
// Cleanup
unsubscribe();
bus.dispose();ViewModel Events
ViewModels support an optional second generic parameter for typed events — fire-and-forget signals for toasts, navigation, animations, etc.
interface SaveEvents {
saved: { id: string };
error: { message: string };
}
class TodoVM extends ViewModel<TodoState, SaveEvents> {
async save() {
try {
const result = await this.api.save(this.state);
this.emit('saved', { id: result.id }); // protected, type-safe
} catch {
this.emit('error', { message: 'Save failed' });
}
}
}
// React — subscribe directly on the ViewModel
const [state, vm] = useLocal(TodoVM);
useEvent(vm, 'saved', ({ id }) => toast.success(`Saved ${id}`));eventsgetter is lazy (zero cost if never accessed)emit()is protected — only the ViewModel can emit- Event bus auto-disposes with the ViewModel
- When
Eis omitted (default{}), everything works as before (backward-compatible)
Async Tracking
After init(), ViewModel automatically tracks loading and error state for every async method — no manual boilerplate needed.
Before (manual tracking):
class UsersVM extends ViewModel<{ users: User[]; loading: boolean; error: string | null }> {
async fetchUsers() {
this.set({ loading: true, error: null });
try {
const res = await fetch('/api/users', { signal: this.disposeSignal });
this.set({ users: await res.json(), loading: false });
} catch (e) {
if (isAbortError(e)) return;
this.set({ loading: false, error: e.message });
throw e;
}
}
}After (automatic tracking):
class UsersVM extends ViewModel<{ users: User[] }> {
async fetchUsers() {
const res = await fetch('/api/users', { signal: this.disposeSignal });
this.set({ users: await res.json() });
}
}
const vm = new UsersVM({ users: [] });
vm.init();
// Automatic — no manual loading/error state needed
vm.async.fetchUsers // → { loading: false, error: null }
vm.fetchUsers();
vm.async.fetchUsers // → { loading: true, error: null }
await vm.fetchUsers();
vm.async.fetchUsers // → { loading: false, error: null }TaskState
interface TaskState {
readonly loading: boolean;
readonly error: string | null;
}Each method key in vm.async returns a frozen TaskState snapshot. Unknown keys return the default { loading: false, error: null }.
Concurrent calls
Loading state is counter-based. If you call the same method multiple times concurrently, loading stays true until all calls complete:
vm.fetchUsers(); // loading: true (count: 1)
vm.fetchUsers(); // loading: true (count: 2)
// first resolves → loading: true (count: 1)
// second resolves → loading: false (count: 0)Error handling
- Normal errors are captured in
TaskState.error(as a string message) AND re-thrown — standard Promise rejection behavior is preserved - AbortErrors are silently swallowed by the wrapper — not captured in
TaskState.error, not re-thrown from the outer promise
For methods without try/catch, AbortError handling is fully automatic. For methods with explicit try/catch, see Error Utilities for when isAbortError() is needed.
Sync method pruning
Sync methods are auto-detected on first call. If a method returns a non-thenable, its wrapper is replaced with a direct bind() — zero overhead after the first call.
subscribeAsync(listener)
Low-level subscription for async state changes. Mirrors the subscribe() contract. Used internally by useInstance() to trigger React re-renders when async status changes.
const unsub = vm.subscribeAsync(() => {
console.log(vm.async.fetchUsers);
});Reserved keys
async and subscribeAsync are reserved property names on ViewModel. Subclasses that define these as properties, methods, or getters throw immediately.
Ghost detection (DEV)
After dispose(), if async methods had pending calls, a warning is logged after GHOST_TIMEOUT (default 3s, configurable via static GHOST_TIMEOUT):
[mvc-kit] Ghost async operation detected: "fetchUsers" had 1 pending call(s)
when the ViewModel was disposed. Consider using disposeSignal to cancel in-flight work.Error Utilities
Composable error handling utilities for consistent error classification.
import { isAbortError, classifyError, HttpError } from 'mvc-kit';
// In services — throw typed HTTP errors
if (!res.ok) throw new HttpError(res.status, res.statusText);
// In ViewModel catch blocks — guard shared-state side effects on abort
if (!isAbortError(e)) rollback();
// Classify any error into a canonical shape
const appError = classifyError(error);
// appError.code: 'unauthorized' | 'network' | 'timeout' | 'abort' | ...When to use isAbortError(): The async tracking wrapper swallows AbortErrors at the outer promise level, but your catch blocks do receive them. Use isAbortError() only when the catch block has side effects on shared state (like rolling back optimistic updates on a singleton Collection). You don't need it for set() or emit() (both are no-ops after dispose), and you never need it in methods without try/catch.
Signal & Cleanup
Every class in mvc-kit has a built-in AbortSignal and cleanup registration system. This eliminates the need to manually track timers, subscriptions, and in-flight requests.
disposeSignal (public)
A lazily-created AbortSignal that is automatically aborted when dispose() is called. Zero overhead if never accessed.
class ChatViewModel extends ViewModel<{ messages: Message[] }> {
protected onInit() {
this.loadMessages();
}
private async loadMessages() {
// fetch() throws AbortError if disposeSignal is aborted — no need for defensive checks after await
const res = await fetch('/api/messages', { signal: this.disposeSignal });
const messages = await res.json();
this.set({ messages });
}
}subscribeTo(source, listener) (protected)
Subscribe to a Subscribable and auto-unsubscribe on dispose. Available on ViewModel, Model, and Controller. Use for imperative reactions — for deriving values from collections, use getters instead (auto-tracking handles reactivity).
class ChatViewModel extends ViewModel<State> {
protected onInit() {
// Imperative reaction: play sound on new messages
this.subscribeTo(this.messagesCollection, () => this.playNotificationSound());
}
}listenTo(source, event, handler) (protected)
Subscribe to a typed event on a Channel or EventBus with automatic cleanup on dispose (and reset, for ViewModels). The event counterpart to subscribeTo.
class ChatViewModel extends ViewModel<State> {
private channel = singleton(ChatChannel);
protected onInit() {
this.listenTo(this.channel, 'message', (msg) => {
this.set({ messages: [...this.state.messages, msg] });
});
}
}Available on ViewModel, Controller, Channel, and Model.
addCleanup(fn) (protected)
Register a teardown callback that fires on dispose(), after disposeSignal abort but before onDispose(). Use for timers and external subscriptions not covered by subscribeTo or listenTo.
class DashboardController extends Controller {
protected onInit() {
const id = setInterval(() => this.poll(), 5000);
this.addCleanup(() => clearInterval(id));
}
}Disposal order
_disposed = true → disposeSignal.abort() → addCleanup callbacks → onDispose() → clear internal data- Signal aborts before
onDispose(), sothis.disposeSignal.aborted === trueinsideonDispose() - Cleanup callbacks fire in registration order
- If
disposeSignalwas never accessed, the abort step is skipped (zero cost) - Re-created singletons after
teardown()get a fresh, un-aborted disposeSignal
Composing signals
For per-call cancellation (e.g., rapid room switching), compose with AbortSignal.any():
class ChatService extends Service {
async loadRoom(roomId: string, callSignal: AbortSignal) {
// Cancelled if EITHER the service is disposed OR the caller aborts
const res = await fetch(`/api/rooms/${roomId}`, {
signal: AbortSignal.any([this.disposeSignal, callSignal]),
});
return res.json();
}
}Singleton Registry
Manage shared instances globally.
import { singleton, hasSingleton, teardown, teardownAll } from 'mvc-kit';
// Get or create singleton
const api = singleton(ApiService);
// Singleton ViewModel with DEFAULT_STATE — no args needed at call sites
class AuthViewModel extends ViewModel<AuthState> {
static DEFAULT_STATE: AuthState = { user: null, isAuthenticated: false };
}
const auth = singleton(AuthViewModel); // uses DEFAULT_STATE automatically
// Check if singleton exists
hasSingleton(ApiService); // true
// Dispose specific singleton
teardown(ApiService);
// Dispose all singletons (useful in tests)
teardownAll();React Integration
import { useInstance, useLocal, useSingleton } from 'mvc-kit/react';Generic Hooks
useInstance(subscribable)
Subscribe to an existing Subscribable. No ownership - you manage disposal.
function Counter({ vm }: { vm: CounterViewModel }) {
const state = useInstance(vm);
return <div>{state.count}</div>;
}useLocal(Class, ...args) or useLocal(factory)
Create component-scoped instance. Auto-disposed on unmount. If the instance has an onInit() hook, it is called automatically after mount.
// Class-based
function Counter() {
const [state, vm] = useLocal(CounterViewModel, { count: 0 });
// vm.onInit() called automatically after mount — no useEffect needed
return <button onClick={() => vm.increment()}>{state.count}</button>;
}
// Factory-based (for complex initialization)
function Checkout() {
const controller = useLocal(() => new CheckoutController(cart, api));
return <button onClick={() => controller.submit()}>Submit</button>;
}Return type:
- Subscribable →
[state, instance]tuple - Disposable-only →
instance
useSingleton(Class, ...args)
Get singleton instance. Registry manages lifecycle. Calls init() automatically after mount.
// Subscribable singleton
function UserProfile() {
const [state, vm] = useSingleton(UserViewModel);
return <div>{state.name}</div>;
}
// Service singleton
function Dashboard() {
const api = useSingleton(ApiService);
// ...
}Model Hooks
useModel(factory)
Create component-scoped Model with validation and dirty state. Calls init() automatically after mount.
function UserForm() {
const { state, errors, valid, dirty, model } = useModel(() =>
new UserModel({ name: '', email: '' })
);
return (
<form>
<input
value={state.name}
onChange={e => model.setName(e.target.value)}
/>
{errors.name && <span>{errors.name}</span>}
<button disabled={!valid}>Submit</button>
</form>
);
}useModelRef(factory)
Create component-scoped Model with lifecycle management (init + dispose) but no subscription. The parent never re-renders from model state changes. Use with useField for per-field isolation in large forms.
function UserForm() {
const model = useModelRef(() => new UserModel({ name: '', email: '' }));
return (
<form>
<NameField model={model} />
<FormActions model={model} />
</form>
);
}useField(model, key)
Subscribe to a single field with surgical re-renders. The returned set() calls the Model's set() directly — use custom setter methods on the Model for any logic beyond simple assignment.
function NameField({ model }: { model: UserModel }) {
const { value, error, set } = useField(model, 'name');
return (
<div>
<input value={value} onChange={e => set(e.target.value)} />
{error && <span>{error}</span>}
</div>
);
}EventBus Hooks
useEvent(bus, event, handler)
Subscribe to event, auto-unsubscribes on unmount.
function NotificationToast() {
const [message, setMessage] = useState('');
useEvent(bus, 'notification', ({ message }) => {
setMessage(message);
});
return message ? <div>{message}</div> : null;
}useEmit(bus)
Get stable emit function.
function LoginButton() {
const emit = useEmit(bus);
return (
<button onClick={() => emit('user:login', { userId: '123' })}>
Login
</button>
);
}DI & Testing
Provider and useResolve
Dependency injection for testing and Storybook.
// In tests/stories
<Provider provide={[
[ApiService, mockApi],
[UserViewModel, mockUserVM]
]}>
<MyComponent />
</Provider>
// In components - falls back to singleton() if no Provider
function MyComponent() {
const api = useResolve(ApiService);
// ...
}useTeardown(...Classes)
Teardown singletons on unmount.
function App() {
// Clean up these singletons when App unmounts
useTeardown(UserViewModel, CartViewModel);
return <Main />;
}API Reference
Core Classes
| Class | Description |
|-------|-------------|
| ViewModel<S, E?> | Reactive state container with optional typed events |
| Model<S> | Reactive entity with validation/dirty tracking |
| Collection<T> | Reactive typed array with CRUD |
| Resource<T> | Collection + async tracking + external Collection injection |
| Controller | Stateless orchestrator (Disposable) |
| Service | Non-reactive infrastructure service (Disposable) |
| EventBus<E> | Typed pub/sub event bus |
| Trackable | Base class for custom reactive objects (subscribable + disposable + auto-bind) |
Composable Helpers
All composable helpers extend Trackable — subscribable, disposable, and auto-bound.
| Class | Description |
|-------|-------------|
| Sorting<T> | Multi-column sort state with 3-click toggle cycle and apply() pipeline |
| Pagination | Page/pageSize state with apply() slicing |
| Selection<K> | Key-based selection set with toggle/select-all semantics |
| Feed<T> | Cursor + hasMore + item accumulation for server-side pagination |
| Pending<K, Meta?> | Per-item operation queue with retry + status tracking + optional typed metadata |
Interfaces
| Interface | Description |
|-----------|-------------|
| Subscribable<S> | Has state, subscribe(), dispose(), disposeSignal |
| Disposable | Has disposed, disposeSignal, dispose() |
| Initializable | Has initialized, init() |
| Listener<S> | (state: S, prev: S) => void |
| Updater<S> | (prev: S) => Partial<S> |
| ValidationErrors<S> | Partial<Record<keyof S, string>> |
| TaskState | { loading: boolean; error: string \| null } |
| AsyncMethodKeys<T> | Union of method names on T that return Promise (ViewModel) |
| ResourceAsyncMethodKeys<T> | Union of method names on T that return Promise (Resource) |
Singleton Functions
| Function | Description |
|----------|-------------|
| singleton(Class, ...args) | Get or create singleton |
| hasSingleton(Class) | Check if singleton exists |
| teardown(Class) | Dispose and remove singleton |
| teardownAll() | Dispose all singletons |
Error Utilities
| Export | Description |
|--------|-------------|
| AppError (type) | Canonical error shape with typed code field |
| 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 Hooks
| Hook | Description |
|------|-------------|
| useInstance(subscribable) | Subscribe to existing instance |
| useLocal(Class \| factory, ...args) | Component-scoped, auto-disposed, auto-init |
| useSingleton(Class, ...args) | Singleton, registry-managed, auto-init |
| useModel(factory) | Model with validation/dirty state, auto-init |
| useModelRef(factory) | Model lifecycle only (no subscription). For per-field forms. |
| useField(model, key) | Single field subscription |
| useEvent(source, event, handler) | Subscribe to EventBus or ViewModel event |
| useEmit(bus) | Get stable emit function |
| useResolve(Class, ...args) | Resolve from Provider or singleton |
| useTeardown(...Classes) | Teardown singletons on unmount |
Headless React Components
| Component | Description |
|-----------|-------------|
| DataTable<T> | Unstyled table with sort headers, selection checkboxes, pagination slots; accepts helper instances directly |
| CardList<T> | Unstyled list/grid with render-prop items |
| InfiniteScroll | IntersectionObserver wrapper for infinite loading; direction="up" for chat UIs |
Behavior Notes
- State is always shallow-frozen with
Object.freeze() - Updates are skipped if no values change (shallow equality)
dispose()is idempotent (safe to call multiple times)init()is idempotent (safe to call multiple times, only runsonInit()once)- On ViewModel,
set()andemit()are no-ops after dispose (not throws) — allows in-flight async callbacks to resolve harmlessly and cleanup callbacks to emit final events - Other mutation methods (
commit(),add(), etc.) throw after dispose subscribe()/on()return a no-op unsubscriber after dispose (does not throw)- Lifecycle:
construct → init → use → disposeonInit()runs once afterinit()— supports sync and async (void | Promise<void>)onSet(prev, next)runs after every state change (ViewModel, Model)onDispose()runs once on dispose, after disposeSignal abort and cleanup callbacks
disposeSignalis lazily created — zero memory/GC overhead unless accessed- Disposal order:
_disposed = true → disposeSignal.abort() → addCleanup callbacks → onDispose() → clear internal data - All six core classes support
disposeSignal,addCleanup(), andonDispose()(including EventBus and Collection) - ViewModel, Model, and Controller also have
subscribeTo(source, listener)for auto-cleaned subscriptions - ViewModel has built-in typed events via optional second generic
E—eventsgetter,emit()method - After
init(), all subclass methods are wrapped for automatic async tracking;vm.async.methodNamereturnsTaskState - Sync methods are auto-pruned on first call — zero overhead after detection
- Async errors are re-thrown (preserves standard Promise rejection); AbortErrors are silently swallowed by the wrapper (but internal catch blocks do receive them — use
isAbortError()to guard shared-state side effects like Collection rollbacks) asyncandsubscribeAsyncare reserved property names on ViewModel and Resource- React hooks (
useLocal,useModel,useSingleton) auto-callinit()after mount singleton()does not auto-callinit()— call it manually outside React- StrictMode safe: the
_initializedguard prevents double-init during React's double-mount cycle;disposeSignalis not aborted during StrictMode's fake unmount/remount cycle __MVC_KIT_DEV__enables development-only safety checks (e.g., detectingset()inside getters). It defaults tofalsesafely — no bundler config required. See Dev Mode.
Detailed Documentation
Each core class and React hook has a dedicated reference doc with full API details, usage patterns, and examples.
Core Classes & Utilities
| Doc | Description |
|-----|-------------|
| ViewModel | State management, computed getters, async tracking, typed events, lifecycle hooks |
| Model | Validation, dirty tracking, commit/rollback for entity forms |
| Collection | Reactive typed array, CRUD, optimistic updates, shared data cache |
| Resource | Collection + async tracking toolkit 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 event communication |
| Channel | Persistent connections (WebSocket, SSE) with auto-reconnect |
| Trackable | Base class for custom reactive objects (subscribable + disposable + auto-bind) |
| Singleton Registry | Global instance management: singleton(), teardown(), teardownAll() |
| Sorting | Multi-column sort state with 3-click toggle cycle and apply pipeline |
| Pagination | Page/pageSize state with array slicing |
| Selection | Key-based selection set with toggle/select-all |
| Feed | Cursor + hasMore + item accumulation for server-side pagination |
| Pending | Per-item operation queue with retry + status tracking |
React Hooks
| Doc | Description | |-----|-------------| | useLocal | Component-scoped instance, auto-init/dispose, deps array for recreate | | useInstance | Subscribe to an existing Subscribable (no lifecycle management) | | useSingleton | Singleton resolution with auto-init and shared state | | useModel, useModelRef & useField | Model binding with validation/dirty state; lifecycle-only ref; surgical per-field subscriptions | | useEvent & useEmit | Subscribe to and emit typed events from EventBus or ViewModel | | useTeardown | Dispose singleton instances on component unmount |
Headless Components
| Doc | Description |
|-----|-------------|
| DataTable | Unstyled table with sort, selection, pagination; accepts helpers directly |
| CardList | Unstyled list/grid with render-prop items |
| InfiniteScroll | IntersectionObserver wrapper for infinite loading; direction="up" for chat UIs |
Dev Mode (__MVC_KIT_DEV__)
mvc-kit includes development-only safety checks guarded by the __MVC_KIT_DEV__ flag. When enabled, these checks catch common mistakes at development time with clear console.error messages instead of silent infinite loops or hard-to-debug failures.
The flag defaults to false safely — no bundler config is required. The library uses a typeof guard internally, so importing mvc-kit in Node, Deno, SSR, or any unbundled environment works without a ReferenceError.
Current checks
set()inside a getter — Afterinit(), ViewModel getters are auto-memoized and dependency-tracked. Callingset()from a getter creates an infinite loop (state change → getter recompute →set()→ repeat). The dev guard detects this, logs an error, and prevents theset()call.- Ghost async operations — After
dispose(), if async methods had pending calls, a warning is logged afterGHOST_TIMEOUT(default 3s). Suggests usingdisposeSignalto cancel in-flight work. - Method call after dispose — Warning when calling a wrapped method after the ViewModel is disposed. The call is ignored and returns
undefined. - Reserved key override — Throws immediately if a subclass defines
asyncorsubscribeAsyncas a property, method, or getter. - Method call before init — Warning when calling a wrapped method before
init(). The method still executes, but async tracking is not yet active.
Enabling dev mode
With a bundler (recommended)
Define __MVC_KIT_DEV__ as true in your bundler's compile-time define config:
Vite
// vite.config.ts
export default defineConfig({
define: { __MVC_KIT_DEV__: true },
// ...
});Without a bundler
Set the global before importing mvc-kit:
globalThis.__MVC_KIT_DEV__ = true;
import { ViewModel } from 'mvc-kit';Production
Set __MVC_KIT_DEV__ to false in your production config (or omit it entirely). The guarded code is dead-code-eliminated by minifiers, resulting in zero runtime cost.
// vite.config.ts — production
export default defineConfig({
define: {
__MVC_KIT_DEV__: process.env.NODE_ENV !== 'production',
},
});How it works
Internally, mvc-kit resolves the flag once at module load:
const __DEV__ = typeof __MVC_KIT_DEV__ !== 'undefined' && __MVC_KIT_DEV__;This is the same pattern used by Vue and Preact. Without a bundler, typeof returns 'undefined' and the constant is false — safe, no crash. With a bundler define, the raw reference is replaced at build time: const __DEV__ = true (or false), and minifiers eliminate dead branches entirely.
License
MIT
