npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

AI 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 only

Claude 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: 1

Core 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 state

Collection

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 array

Persistent 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}`));
  • events getter is lazy (zero cost if never accessed)
  • emit() is protected — only the ViewModel can emit
  • Event bus auto-disposes with the ViewModel
  • When E is 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(), so this.disposeSignal.aborted === true inside onDispose()
  • Cleanup callbacks fire in registration order
  • If disposeSignal was 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 runs onInit() once)
  • On ViewModel, set() and emit() 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 → dispose
    • onInit() runs once after init() — 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
  • disposeSignal is 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(), and onDispose() (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 Eevents getter, emit() method
  • After init(), all subclass methods are wrapped for automatic async tracking; vm.async.methodName returns TaskState
  • 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)
  • async and subscribeAsync are reserved property names on ViewModel and Resource
  • React hooks (useLocal, useModel, useSingleton) auto-call init() after mount
  • singleton() does not auto-call init() — call it manually outside React
  • StrictMode safe: the _initialized guard prevents double-init during React's double-mount cycle; disposeSignal is not aborted during StrictMode's fake unmount/remount cycle
  • __MVC_KIT_DEV__ enables development-only safety checks (e.g., detecting set() inside getters). It defaults to false safely — 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 — After init(), ViewModel getters are auto-memoized and dependency-tracked. Calling set() from a getter creates an infinite loop (state change → getter recompute → set() → repeat). The dev guard detects this, logs an error, and prevents the set() call.
  • Ghost async operations — After dispose(), if async methods had pending calls, a warning is logged after GHOST_TIMEOUT (default 3s). Suggests using disposeSignal to 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 async or subscribeAsync as 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