A minimal MVVM framework for building reactive web applications.
npm install @web-loom/mvvm-coreFramework-agnostic MVVM library for building reactive web applications with RxJS and Zod validation.
@web-loom/mvvm-core provides a complete MVVM (Model-View-ViewModel) implementation that works across React, Angular, Vue, and vanilla JavaScript. Built on RxJS for reactive data flow and Zod for type-safe validation, it simplifies state management and API interactions for client-heavy applications.
``bash`
npm install @web-loom/mvvm-core rxjs zod
- MVVM Pattern: BaseModel, BaseViewModel, RestfulApiModel with clear separation of concerns
- Reactive: RxJS-powered observables for data$, isLoading$, error$canExecute
- Type-Safe: Zod schema validation at compile-time and runtime
- RESTful APIs: Simplified CRUD with optimistic updates and auto state management
- Command Pattern: Encapsulated UI actions with and isExecuting states@web-loom/query-core
- Observable Collections: Reactive lists with granular change notifications
- Query Integration: QueryStateModel for advanced caching with
- Resource Management: IDisposable pattern for proper cleanup
- Framework Agnostic: No UI framework dependencies
Foundation for all models with reactive state management.
`typescript
import { BaseModel } from '@web-loom/mvvm-core';
import { z } from 'zod';
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(3),
email: z.string().email(),
age: z.number().positive().optional(),
});
type User = z.infer
class UserModel extends BaseModel
constructor(initialData?: User) {
super({ initialData: initialData || null, schema: UserSchema });
}
}
const model = new UserModel();
model.data$.subscribe((user) => console.log('User:', user));
model.setData({ id: '123', name: 'Alice', email: 'alice@example.com' });
`
Key observables:
- data$: Current data stateisLoading$
- : Loading indicatorerror$
- : Error stateisError$
- : Boolean error indicator
Extends BaseModel with CRUD operations and optimistic updates.
`typescript
import { RestfulApiModel, type Fetcher } from '@web-loom/mvvm-core';
const fetcher: Fetcher = async (url, options) => {
const response = await fetch(url, options);
if (!response.ok) throw new Error(HTTP ${response.status});
return response;
};
class UserApiModel extends RestfulApiModel
constructor() {
super({
baseUrl: 'https://api.example.com',
endpoint: 'users',
fetcher,
schema: z.array(UserSchema),
initialData: null,
});
}
}
const api = new UserApiModel();
// Fetch all users
await api.fetch();
// Create user (optimistic update)
const newUser = await api.create({ name: 'Bob', email: 'bob@example.com' });
// Update user
await api.update('user-id', { name: 'Robert' });
// Delete user
await api.delete('user-id');
`
Features:
- Automatic loading state management
- Optimistic updates with rollback on error
- Error handling with retry logic
- Validation via Zod schemas
Connects Models to Views with presentation logic.
`typescript
import { BaseViewModel } from '@web-loom/mvvm-core';
import { map } from 'rxjs/operators';
class UserViewModel extends BaseViewModel
constructor(model: UserModel) {
super(model);
}
// Computed observables
get displayName$() {
return this.data$.pipe(map((user) => (user ? ${user.name} (${user.email}) : 'No user')));`
}
}
Extends BaseViewModel with CRUD commands for RESTful operations.
`typescript
import { RestfulApiViewModel } from '@web-loom/mvvm-core';
class UserListViewModel extends RestfulApiViewModel
constructor() {
super(new UserApiModel());
}
// Additional computed properties
get activeUsers$() {
return this.data$.pipe(map((users) => users?.filter((u) => u.active)));
}
}
const vm = new UserListViewModel();
// Use commands
await vm.fetchCommand.execute();
await vm.createCommand.execute({ name: 'New User', email: 'new@example.com' });
await vm.updateCommand.execute({ id: '123', name: 'Updated' });
await vm.deleteCommand.execute('123');
// Clean up
vm.dispose();
`
Encapsulates UI actions with execution control.
`typescript
import { Command } from '@web-loom/mvvm-core';
import { BehaviorSubject } from 'rxjs';
import { map } from 'rxjs/operators';
class AuthViewModel {
private _isLoggedIn = new BehaviorSubject(false);
isLoggedIn$ = this._isLoggedIn.asObservable();
loginCommand: Command
constructor() {
this.loginCommand = new Command(
async (password: string) => {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
const success = password === 'secret';
this._isLoggedIn.next(success);
return success;
},
// canExecute$ - only when not logged in
this.isLoggedIn$.pipe(map((loggedIn) => !loggedIn)),
);
}
}
const auth = new AuthViewModel();
// Subscribe to command state
auth.loginCommand.isExecuting$.subscribe((executing) => console.log('Logging in:', executing));
auth.loginCommand.canExecute$.subscribe((canExecute) => console.log('Can login:', canExecute));
// Execute command
await auth.loginCommand.execute('secret');
`
Command features:
- isExecuting$: Track execution statecanExecute$
- : Control when command can runresult$
- : Observable of command results
- Automatic error handling
Reactive collection with granular change notifications.
`typescript
import { ObservableCollection } from '@web-loom/mvvm-core';
interface Todo {
id: string;
text: string;
completed: boolean;
}
const todos = new ObservableCollection
{ id: '1', text: 'Learn MVVM', completed: false },
{ id: '2', text: 'Build app', completed: true },
]);
// Subscribe to changes
todos.items$.subscribe((items) => console.log('Todos:', items));
todos.changes$.subscribe((change) => console.log('Change:', change));
// Manipulate collection
todos.add({ id: '3', text: 'Deploy', completed: false });
todos.update((todo) => todo.id === '1', { ...todo, completed: true });
todos.remove((todo) => todo.completed);
// Query collection
const array = todos.toArray();
const count = todos.count();
const firstUncompleted = todos.find((todo) => !todo.completed);
`
Integration with @web-loom/query-core for advanced caching.
`typescript
import { QueryStateModel, QueryStateModelView } from '@web-loom/mvvm-core';
import QueryCore from '@web-loom/query-core';
const queryCore = new QueryCore({ defaultRefetchAfter: 5 60 1000 });
// Define endpoint
queryCore.defineEndpoint
const res = await fetch('https://api.example.com/users');
return res.json();
});
// Create model
class UsersQueryModel extends QueryStateModel
constructor() {
super({
queryCore,
endpointKey: 'users',
schema: z.array(UserSchema),
});
}
}
// Create ViewModel
class UsersViewModel extends QueryStateModelView
constructor() {
super(new UsersQueryModel());
}
}
const vm = new UsersViewModel();
// Subscribe to data
vm.data$.subscribe((users) => console.log('Users:', users));
// Refetch data
await vm.refetchCommand.execute(true); // Force refetch
// Invalidate cache
await vm.invalidateCommand.execute();
`
Benefits:
- Shared cache across components
- Automatic background refetching
- Request deduplication
- Stale-while-revalidate pattern
`tsx
import { useState, useEffect, useMemo } from 'react';
import { Observable } from 'rxjs';
// Custom hook for RxJS observables
function useObservable
const [value, setValue] = useState
useEffect(() => {
const subscription = observable.subscribe(setValue);
return () => subscription.unsubscribe();
}, [observable]);
return value;
}
// Component
function UserList() {
const vm = useMemo(() => new UserListViewModel(), []);
const users = useObservable(vm.data$, null);
const isLoading = useObservable(vm.isLoading$, false);
const error = useObservable(vm.error$, null);
useEffect(() => {
vm.fetchCommand.execute();
return () => vm.dispose();
}, [vm]);
if (isLoading) return
return (
$3
`typescript
import { Component, OnInit, OnDestroy } from '@angular/core';
import { UserListViewModel } from './viewmodels/user-list.viewmodel';@Component({
selector: 'app-user-list',
template:
,
providers: [UserListViewModel],
})
export class UserListComponent implements OnInit, OnDestroy {
constructor(public vm: UserListViewModel) {} ngOnInit() {
this.vm.fetchCommand.execute();
}
ngOnDestroy() {
this.vm.dispose();
}
}
`$3
`vue
Loading...
Error: {{ error.message }}
- {{ user.name }}
`Advanced Features
$3
Form state management with validation and dirty tracking.
`typescript
import { FormViewModel } from '@web-loom/mvvm-core';const formVm = new FormViewModel({
initialValues: { email: '', password: '' },
validationSchema: z.object({
email: z.string().email(),
password: z.string().min(8),
}),
validateOnChange: true,
validateOnBlur: true,
});
// Subscribe to form state
formVm.isValid$.subscribe((valid) => console.log('Valid:', valid));
formVm.isDirty$.subscribe((dirty) => console.log('Dirty:', dirty));
formVm.errors$.subscribe((errors) => console.log('Errors:', errors));
// Set field values
formVm.setFieldValue('email', 'user@example.com');
// Submit form
formVm.submitCommand.execute();
`$3
Advanced list management with filtering, sorting, and pagination.
`typescript
import { QueryableCollectionViewModel } from '@web-loom/mvvm-core';const vm = new QueryableCollectionViewModel({
items: users,
pageSize: 10,
});
// Filter
vm.setFilter((user) => user.active);
// Sort
vm.setSortBy('name', 'asc');
// Paginate
vm.nextPage();
vm.previousPage();
vm.goToPage(2);
// Subscribe to results
vm.filteredItems$.subscribe((items) => console.log('Filtered:', items));
vm.currentPage$.subscribe((items) => console.log('Current page:', items));
`$3
`typescript
import { DIContainer } from '@web-loom/mvvm-core';const container = new DIContainer();
// Register singleton
container.registerSingleton('UserService', () => new UserService());
// Register transient
container.registerTransient('UserViewModel', () => new UserViewModel());
// Resolve
const userService = container.resolve('UserService');
const userVm = container.resolve('UserViewModel');
`Best Practices
1. Always dispose ViewModels: Call
dispose() when components unmount
2. Use schemas for validation: Define Zod schemas for all data types
3. Leverage computed observables: Derive state with RxJS operators
4. Handle errors properly: Subscribe to error$ and display to users
5. Optimize subscriptions: Use takeUntil pattern to prevent memory leaks
6. Test business logic: ViewModels are framework-agnostic and easily testableTesting
`typescript
import { describe, it, expect } from 'vitest';
import { UserViewModel } from './user.viewmodel';describe('UserViewModel', () => {
it('should fetch users', async () => {
const vm = new UserViewModel();
await vm.fetchCommand.execute();
expect(vm.getState().data).toBeDefined();
expect(vm.getState().isLoading).toBe(false);
vm.dispose();
});
});
`API Reference
$3
-
data$: BehaviorSubject
- isLoading$: BehaviorSubject
- error$: BehaviorSubject
- isError$: Observable
- setData(data: T): void
- setLoading(loading: boolean): void
- setError(error: Error | null): void
- dispose(): void$3
-
fetch(): Promise
- create(data: Partial
- update(id: string, data: Partial
- delete(id: string): Promise$3
-
data$: Observable
- isLoading$: Observable
- error$: Observable
- getState(): ModelState
- dispose(): void$3
-
fetchCommand: Command
- createCommand: Command, T | null>
- updateCommand: Command<{ id: string; data: Partial }, T | null>
- deleteCommand: Command$3
-
isExecuting$: Observable
- canExecute$: Observable
- result$: Observable
- execute(param: TParam): Promise
- dispose(): voidTypeScript Support
Full TypeScript support with comprehensive type definitions:
`typescript
import type { IModel, IViewModel, ICommand, IDisposable, ModelState, Fetcher } from '@web-loom/mvvm-core';
``- rxjs: ^7.8.2 (reactive programming)
- zod: ^3.25.0 (schema validation)
- @web-loom/query-core: 0.0.3 (optional, for QueryStateModel)
MIT