New Research: Supply Chain Attack on Axios Pulls Malicious Dependency from npm.Details →
Socket
Book a DemoSign in
Socket

mvc-kit

Package Overview
Dependencies
Maintainers
1
Versions
30
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

mvc-kit

Zero-magic, class-based reactive ViewModel library

latest
npmnpm
Version
2.12.5
Version published
Weekly downloads
78
-87.66%
Maintainers
1
Weekly downloads
 
Created
Source

mvc-kit

mvc-kit logo

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

ClassDescription
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
ControllerStateless orchestrator (Disposable)
ServiceNon-reactive infrastructure service (Disposable)
EventBus<E>Typed pub/sub event bus
TrackableBase class for custom reactive objects (subscribable + disposable + auto-bind)

Composable Helpers

All composable helpers extend Trackable — subscribable, disposable, and auto-bound.

ClassDescription
Sorting<T>Multi-column sort state with 3-click toggle cycle and apply() pipeline
PaginationPage/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

InterfaceDescription
Subscribable<S>Has state, subscribe(), dispose(), disposeSignal
DisposableHas disposed, disposeSignal, dispose()
InitializableHas 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

FunctionDescription
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

ExportDescription
AppError (type)Canonical error shape with typed code field
HttpErrorTyped 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

HookDescription
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

ComponentDescription
DataTable<T>Unstyled table with sort headers, selection checkboxes, pagination slots; accepts helper instances directly
CardList<T>Unstyled list/grid with render-prop items
InfiniteScrollIntersectionObserver 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

DocDescription
ViewModelState management, computed getters, async tracking, typed events, lifecycle hooks
ModelValidation, dirty tracking, commit/rollback for entity forms
CollectionReactive typed array, CRUD, optimistic updates, shared data cache
ResourceCollection + async tracking toolkit with external Collection injection
ControllerStateless orchestrator for multi-ViewModel coordination
ServiceNon-reactive infrastructure adapters (HTTP, storage, SDKs)
EventBusTyped pub/sub for cross-cutting event communication
ChannelPersistent connections (WebSocket, SSE) with auto-reconnect
TrackableBase class for custom reactive objects (subscribable + disposable + auto-bind)
Singleton RegistryGlobal instance management: singleton(), teardown(), teardownAll()
SortingMulti-column sort state with 3-click toggle cycle and apply pipeline
PaginationPage/pageSize state with array slicing
SelectionKey-based selection set with toggle/select-all
FeedCursor + hasMore + item accumulation for server-side pagination
PendingPer-item operation queue with retry + status tracking

React Hooks

DocDescription
useLocalComponent-scoped instance, auto-init/dispose, deps array for recreate
useInstanceSubscribe to an existing Subscribable (no lifecycle management)
useSingletonSingleton resolution with auto-init and shared state
useModel, useModelRef & useFieldModel binding with validation/dirty state; lifecycle-only ref; surgical per-field subscriptions
useEvent & useEmitSubscribe to and emit typed events from EventBus or ViewModel
useTeardownDispose singleton instances on component unmount

Headless Components

DocDescription
DataTableUnstyled table with sort, selection, pagination; accepts helpers directly
CardListUnstyled list/grid with render-prop items
InfiniteScrollIntersectionObserver 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

FAQs

Package last updated on 22 Mar 2026

Did you know?

Socket

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Install

Related posts