@appboypov/veto-mvvm
v1.0.0
Published
MVVM pattern with BaseViewModel class, ViewModelBuilder component, and DI container for React - Flutter inspired
Maintainers
Readme
@appboypov/veto-mvvm
MVVM pattern implementation and lightweight dependency injection container for React, inspired by Flutter.
Installation
npm install @appboypov/veto-mvvmFeatures
- BaseViewModel class - Abstract class with lifecycle management and
notifyListeners() - ViewModelBuilder component - Declarative ViewModel creation and lifecycle management
- Lightweight DI container - Inspired by Flutter's get_it
- React 18 concurrent mode support via
useSyncExternalStore - Zero dependencies (peer dependency on React 18+)
Quick Start
BaseViewModel + ViewModelBuilder
import { BaseViewModel, ViewModelBuilder } from '@appboypov/veto-mvvm';
class CounterViewModel extends BaseViewModel {
private _count = 0;
get count() {
return this._count;
}
increment() {
this._count++;
this.notifyListeners(); // Triggers rebuild
}
async initialise(): Promise<void> {
// Load initial data here
await super.initialise(); // Call LAST
}
dispose(): void {
// Cleanup here
super.dispose(); // Call LAST
}
}
function CounterView() {
return (
<ViewModelBuilder
viewModelBuilder={() => new CounterViewModel()}
builder={(vm, isInitialised) => (
<div>
{isInitialised ? (
<>
<p>Count: {vm.count}</p>
<button onClick={() => vm.increment()}>+</button>
</>
) : (
<p>Loading...</p>
)}
</div>
)}
/>
);
}API - BaseViewModel
BaseViewModel<A>
Abstract base class for ViewModels with lifecycle management.
class MyViewModel extends BaseViewModel<MyArgs> {
// Your ViewModel implementation
}Properties
| Property | Type | Description |
|----------|------|-------------|
| isInitialised | boolean | Whether initialise() has completed |
| isMounted | boolean | Whether component is mounted |
| arguments | A \| undefined | Arguments passed via argumentBuilder |
Methods
| Method | Description |
|--------|-------------|
| initialise() | Async initialization. Call super.initialise() LAST |
| dispose() | Cleanup. Call super.dispose() LAST |
| notifyListeners() | Trigger rebuild of subscribed components |
Example
class UserViewModel extends BaseViewModel<{ userId: string }> {
user: User | null = null;
isLoading = false;
async initialise(): Promise<void> {
this.isLoading = true;
this.notifyListeners();
this.user = await fetchUser(this.arguments!.userId);
this.isLoading = false;
await super.initialise(); // Sets isInitialised = true
}
dispose(): void {
// Cleanup subscriptions, etc.
super.dispose();
}
}API - ViewModelBuilder
ViewModelBuilder<T, A>
Component that creates, manages, and provides a BaseViewModel to its children.
<ViewModelBuilder
viewModelBuilder={() => new MyViewModel()}
argumentBuilder={() => ({ userId: '123' })}
builder={(vm, isInitialised, child) => (
// Your UI here
)}
child={<StaticContent />}
shouldDispose={true}
onDispose={(vm) => console.log('Disposed')}
/>Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| viewModelBuilder | () => T | required | Factory to create ViewModel |
| argumentBuilder | () => A | - | Factory to provide arguments |
| builder | (vm, isInit, child?) => ReactNode | required | Render function |
| child | ReactNode | - | Non-rebuilding child |
| shouldDispose | boolean | true | Dispose ViewModel on unmount |
| onDispose | (vm) => void | - | Callback before dispose |
Example with Arguments
function UserProfile({ userId }: { userId: string }) {
return (
<ViewModelBuilder
viewModelBuilder={() => new UserViewModel()}
argumentBuilder={() => ({ userId })}
builder={(vm, isInitialised) => (
<div>
{!isInitialised ? (
<p>Loading...</p>
) : (
<div>
<h1>{vm.user?.name}</h1>
<p>{vm.user?.email}</p>
</div>
)}
</div>
)}
/>
);
}Example with Non-Rebuilding Child
<ViewModelBuilder
viewModelBuilder={() => new ListViewModel()}
child={<ExpensiveHeader />}
builder={(vm, isInitialised, child) => (
<div>
{child}
<ul>
{vm.items.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
</div>
)}
/>API - Dependency Injection
LocatorService
The DI container class. Access via locator singleton.
Registration Methods
import { locator, locate } from '@appboypov/veto-mvvm';
// Pre-created instance (eager)
locator.registerSingleton('api', new UsersApi());
// Lazy instantiation (created on first access)
locator.registerLazySingleton('service', () => new UsersService(locate('api')));
// New instance every time
locator.registerFactory('dto', () => new UserDto());Retrieval Methods
// Direct access
const service = locator.get<UsersService>('service');
// Shorthand
const service = locate<UsersService>('service');
// React hook (memoized)
function MyComponent() {
const service = useService<UsersService>('service');
}Utility Methods
locator.isRegistered('key'); // Check if registered
locator.unregister('key'); // Remove registration
locator.reset(); // Clear all (for testing)Patterns
Service Registration at Startup
// services/setup.ts
export function setupServices() {
locator.registerLazySingleton('usersApi', () => new UsersApi());
locator.registerLazySingleton('usersService', () =>
new UsersService(locate('usersApi'))
);
}
// main.tsx
setupServices();
ReactDOM.createRoot(root).render(<App />);ViewModel with Service Injection
class UsersViewModel extends BaseViewModel {
private usersService = locate<UsersService>('usersService');
users: User[] = [];
async initialise(): Promise<void> {
this.users = await this.usersService.getUsers();
await super.initialise();
}
}Testing with Mocks
describe('UsersViewModel', () => {
beforeEach(() => {
locator.reset();
locator.registerSingleton('usersService', new MockUsersService());
});
it('loads users on init', async () => {
const vm = new UsersViewModel();
await vm.initialise();
expect(vm.users).toHaveLength(2);
});
});License
MIT
