MVVM Core
A simple yet powerful MVVM state management library for Flutter. Built on Flutter's native primitives with zero external dependencies.
MVVM Architecture
β¨ Features
- π― Simple & Intuitive β Easy to learn, minimal boilerplate
- β‘ Reactive Primitives β
Reactive,ReactiveFuture,ReactiveStream - π¦ Reactive Collections β
ReactiveList,ReactiveMap,ReactiveSet - π Async State Management β Built-in loading, error, and data states
- π οΈ DevTools Integration β Inspect ViewModels in Flutter DevTools
- π§ͺ Testable β Easy to test ViewModels in isolation
- π± Zero Dependencies β Only Flutter SDK required
π¦ Installation
Add mvvm_core to your pubspec.yaml:
dependencies:
mvvm_core: ^1.1.0
Then run:
flutter pub get
π Quick Start
1. Create a ViewModel
import 'package:mvvm_core/mvvm_core.dart';
class CounterViewModel extends ViewModel {
final count = Reactive<int>(0);
void increment() => count.value++;
void decrement() => count.value--;
@override
void dispose() {
count.dispose();
super.dispose();
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty('count', count));
}
}
2. Create a View
class CounterView extends ViewHandler<CounterViewModel> {
const CounterView({super.key});
@override
CounterViewModel viewModelFactory() => CounterViewModel();
@override
Widget build(BuildContext context, CounterViewModel viewModel, Widget? child) {
return Scaffold(
appBar: AppBar(title: const Text('Counter')),
body: Center(
child: viewModel.count.listen(
builder: (context, count, _) =>
Text(
'$count',
style: Theme
.of(context)
.textTheme
.displayLarge,
),
),
),
floatingActionButton: Column(
mainAxisSize: MainAxisSize.min,
children: [
FloatingActionButton(
onPressed: viewModel.increment,
child: const Icon(Icons.add),
),
const SizedBox(height: 8),
FloatingActionButton(
onPressed: viewModel.decrement,
child: const Icon(Icons.remove),
),
],
),
);
}
}
That's it! Your counter app is ready with clean separation between UI and logic.
π Core Concepts
Reactive<T>
A simple reactive wrapper for synchronous values:
final name = Reactive<String>('');
final isEnabled = Reactive<bool>(true);
final count = Reactive<int>(0);
// Read value
print(count.value); // 0
// Update value
count.value = 10;
count.value++;
// Transform value
name.update((current) => current.toUpperCase());
// Listen in UI
count.listen(
builder: (context, value, _) => Text('$value'),
)
ReactiveFuture<T>
Handle async operations with built-in loading/error states:
class UserViewModel extends ViewModel {
final user = ReactiveFuture<User>.idle();
// Call loadUser() from init(), button press, or other triggers
Future<void> loadUser(String id) async {
await user.run(() => userRepository.getUser(id));
}
}
// In the view
vm.user.listenWhen(
idle: () => const Text('Enter ID to search'),
loading: () => const CircularProgressIndicator(),
data: (user) => UserCard(user: user),
error: (e, _) => Text('Error: $e'),
)
ReactiveStream<T>
React to stream events in real-time:
class ChatViewModel extends ViewModel {
final messages = ReactiveStream<Message>.idle();
// Call connect() from init(), button press, or other triggers
void connect(String roomId) {
// chatService.getMessages() returns a Stream<Message> that emits new messages
// We bind this stream to our reactive stream to handle its events
messages.bind(chatService.getMessages(roomId));
}
@override
void dispose() {
messages.cancel();
super.dispose();
}
}
// In the view
vm.messages.listenWhen(
loading: () => const Text('Connecting...'),
data: (message) => MessageBubble(message: message),
error: (e, _) => const Text('Disconnected'),
done: (_) => const Text('Chat ended'),
)
Reactive Collections
Full-featured reactive collections that notify on changes:
// ReactiveList
final todos = ReactiveList<Todo>([]);
todos.add(Todo('Buy milk'));
todos.removeWhere((t) => t.completed);
todos[0] = todos[0].copyWith(completed: true);
// ReactiveMap
final settings = ReactiveMap<String, dynamic>({'theme': 'dark'});
settings['language'] = 'en';
settings.remove('deprecated');
// ReactiveSet
final selectedIds = ReactiveSet<String>();
selectedIds.add('item_1');
selectedIds.remove('item_2');
Batch Operations
Perform multiple updates with a single notification:
// Only one rebuild will be triggered!
todos.batch((list) {
list.add(Todo('Task 1'));
list.add(Todo('Task 2'));
list.removeAt(0);
list.sort((a, b) => a.priority.compareTo(b.priority));
});
π― AsyncState
All async properties use the AsyncState sealed class for type-safe state handling:
| State | Description |
|---|---|
AsyncIdle |
Operation not started |
AsyncLoading |
Operation in progress |
AsyncData |
Success with data |
AsyncError |
Error with details |
StreamDone |
Stream completed |
Pattern Matching
// Exhaustive matching
state.when(
idle: () => const Text('Ready'),
loading: () => const CircularProgressIndicator(),
data: (user) => Text(user.name),
error: (e, stackTrace) => Text('Error: $e'),
// Optional for streams
done: (lastMsg) => Text('Stream ended. Last: ${lastMsg?.content}'),
);
// Partial matching
state.maybeWhen(
error: (e, _) => showErrorSnackbar(e),
orElse: () {},
);
// With previous data access
state.whenWithPrevious(
idle: () => const Text('Ready'),
loading: (previousData) => previousData != null
? RefreshIndicator(child: UserCard(previousData))
: const LoadingSpinner(),
data: (user) => UserCard(user),
error: (e, _, previousData) => ErrorWithRetry(
error: e,
cachedData: previousData,
),
);
π Selective Rebuilds
Optimize performance by only rebuilding when specific values change:
// Only rebuilds when email changes, not when name or age changes
viewModel.user.select(
selector: (user) => user.email,
builder: (context, email) => Text(email),
)
// Select multiple values using records
viewModel.user.select(
selector: (user) => (user.firstName, user.lastName),
builder: (context, names) {
final (first, last) = names;
return Text('$first $last');
},
)
π Multiple Properties
Listen to multiple reactive properties at once:
MultiReactiveBuilder(
properties: [
viewModel.firstName,
viewModel.lastName,
viewModel.age,
],
builder: (context, _) => Text(
'${vm.firstName.value} ${vm.lastName.value}, ${vm.age.value}',
),
)
ποΈ ViewHandler Features
Lifecycle Hooks
class UserProfileView extends ViewHandler<UserProfileViewModel> {
const UserProfileView({super.key, required this.userId});
final String userId;
@override
UserProfileViewModel viewModelFactory() => UserProfileViewModel();
@override
void init(UserProfileViewModel viewModel) {
super.init(viewModel);
// This lifecycle method is also present in the ViewModel
// and do not need to be overridden here
viewModel.loadUser(userId); // Called when view is mounted
}
@override
void dispose(UserProfileViewModel viewModel) {
// This lifecycle method is also present in the ViewModel
// and do not need to be overridden here
viewModel.cancelSubscriptions(); // Called when view is unmounted
super.dispose(viewModel);
}
@override
Widget build(BuildContext context, UserProfileViewModel viewModel, Widget? child) {
// ...
}
}
Child Optimization
Cache expensive widgets that don't need rebuilding:
class TodoListView extends ViewHandler<TodoListViewModel> {
@override
Widget? child(BuildContext context) {
// This widget won't rebuild when ViewModel changes
return const ExpensiveHeader();
}
@override
Widget build(BuildContext context, TodoListViewModel vm, Widget? child) {
return Column(
children: [
child!, // Reused across rebuilds
Expanded(
child: vm.todos.listen(
builder: (context, todos, _) => TodoList(todos: todos),
),
),
],
);
}
}
Navigation Control
Control back navigation with PopScope integration:
class FormView extends ViewHandler<FormViewModel> {
@override
bool get canPop => false; // Prevent back navigation
@override
PopInvokedContextWithResultCallback<dynamic>? get onPopInvokedWithResult =>
(context, didPop, result) {
if (!didPop) {
showDialog(
context: context,
builder: (_) => const DiscardChangesDialog(),
);
}
};
// ...
}
π οΈ DevTools Integration
Inspect your ViewModels in real-time with the built-in DevTools extension.
Setup
-
Enable the MVVM Core DevTools Extension:
- In your Flutter app, navigate to Flutter DevTools
- Go to Settings (
) - Enable the "MVVM Core" extension
-
Override
debugFillPropertiesin your ViewModels:class MyViewModel extends ViewModel { final count = Reactive<int>(0); final user = ReactiveFuture<User>.idle(); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(DiagnosticsProperty('count', count)); properties.add(DiagnosticsProperty('user', user)); } } -
Open DevTools and look for the MVVM Core tab!
π§ͺ Testing
ViewModels are easy to test in isolation:
void main() {
group('CounterViewModel', () {
late CounterViewModel vm;
setUp(() {
vm = CounterViewModel();
});
tearDown(() {
vm.dispose();
});
test('initial count is 0', () {
expect(vm.count.value, equals(0));
});
test('increment increases count', () {
vm.increment();
expect(vm.count.value, equals(1));
});
test('notifies listeners on change', () {
int notifications = 0;
vm.count.addListener(() => notifications++);
vm.increment();
expect(notifications, equals(1));
});
});
group('UserViewModel', () {
late UserViewModel vm;
late MockUserRepository mockRepository;
setUp(() {
mockRepository = MockUserRepository();
vm = UserViewModel(repository: mockRepository);
});
test('loadUser sets loading then data state', () async {
when(() => mockRepository.getUser('123'))
.thenAnswer((_) async => User(id: '123', name: 'John'));
expect(vm.user.value.isIdle, isTrue);
final future = vm.loadUser('123');
expect(vm.user.value.isLoading, isTrue);
await future;
expect(vm.user.value.hasData, isTrue);
expect(vm.user.data?.name, equals('John'));
});
});
}
π Comparison
| Feature | MVVM Core | Bloc | Riverpod | Provider |
|---|---|---|---|---|
| Learning Curve | π’ Easy | π‘ Medium | π‘ Medium | π’ Easy |
| Boilerplate | π’ Minimal | π΄ High | π‘ Medium | π’ Minimal |
| Async Handling | π’ Built-in | π‘ Manual | π’ Built-in | π΄ Manual |
| Collections | π’ Built-in | π΄ Manual | π΄ Manual | π΄ Manual |
| DevTools | π’ Yes | π’ Yes | π’ Yes | π‘ Basic |
| Code Generation | π’ Not needed | π‘ Optional | π‘ Optional | π’ Not needed |
| Dependencies | π’ Zero | π‘ 2+ | π‘ 2+ | π’ Zero |
| Type Safety | π’ Sealed classes | π’ Yes | π’ Yes | π‘ Basic |
π Migration Guide
From setState
// Before
class _MyWidgetState extends State<MyWidget> {
int count = 0;
void increment() => setState(() => count++);
@override
Widget build(BuildContext context) {
return Text('$count');
}
}
// After
class CounterViewModel extends ViewModel {
final count = Reactive<int>(0);
void increment() => count.value++;
}
class CounterView extends ViewHandler<CounterViewModel> {
@override
CounterViewModel viewModelFactory() => CounterViewModel();
@override
Widget build(BuildContext context, CounterViewModel vm, Widget? child) {
return vm.count.listen(
builder: (context, count, _) => Text('$count'),
);
}
}
From Provider/ChangeNotifier
// Before
class CounterProvider extends ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
notifyListeners();
}
}
// After
class CounterViewModel extends ViewModel {
final count = Reactive<int>(0);
void increment() => count.value++;
}
From Bloc/Cubit
// Before
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
}
// After
class CounterViewModel extends ViewModel {
final count = Reactive<int>(0);
void increment() => count.value++;
}
π Examples
Todo App
class TodoViewModel extends ViewModel {
final todos = ReactiveList<Todo>([]);
final filter = Reactive<TodoFilter>(TodoFilter.all);
List<Todo> get filteredTodos =>
switch (filter.value) {
TodoFilter.all => todos.value,
TodoFilter.active => todos.where((t) => !t.completed).toList(),
TodoFilter.completed => todos.where((t) => t.completed).toList(),
};
void addTodo(String title) {
todos.add(Todo(id: uuid(), title: title));
}
void toggleTodo(String id) {
final index = todos.indexWhere((t) => t.id == id);
if (index != -1) {
todos[index] = todos[index].copyWith(
completed: !todos[index].completed,
);
}
}
void deleteTodo(String id) {
todos.removeWhere((t) => t.id == id);
}
void clearCompleted() {
todos.removeWhere((t) => t.completed);
}
}
Authentication Flow
class AuthViewModel extends ViewModel {
final authState = ReactiveFuture<User?>.idle();
final isLoggedIn = Reactive<bool>(false);
Future<void> login(String email, String password) async {
final user = await authState.run(
() => authService.login(email, password),
);
if (user != null) {
isLoggedIn.value = true;
}
}
Future<void> logout() async {
await authService.logout();
isLoggedIn.value = false;
authState.reset();
}
}
Search with Debounce
class SearchViewModel extends ViewModel {
final query = Reactive<String>('');
final results = ReactiveFuture<List<Item>>.idle();
Timer? _debounceTimer;
void onQueryChanged(String value) {
query.value = value;
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 300), () {
if (value.isEmpty) {
results.reset();
} else {
// Calling run() while a previous operation
// is in progress will cancel the previous run
results.run(() => searchService.search(value));
}
});
}
@override
void dispose() {
_debounceTimer?.cancel();
super.dispose();
}
}
π API Reference
See the API documentation for detailed information on all classes and methods.
Core Classes
| Class | Description |
|---|---|
ViewModel |
Base class for all ViewModels |
ViewHandler<T> |
Widget that binds a ViewModel to a view |
Reactive<T> |
Reactive wrapper for synchronous values |
ReactiveFuture<T> |
Reactive wrapper for Future operations |
ReactiveStream<T> |
Reactive wrapper for Stream operations |
ReactiveList<E> |
Reactive List implementation |
ReactiveMap<K, V> |
Reactive Map implementation |
ReactiveSet<E> |
Reactive Set implementation |
AsyncState<T> |
Sealed class for async operation states |
Builder Widgets
| Widget | Description |
|---|---|
ReactiveBuilder<T> |
Rebuilds when a single property changes |
SelectReactiveBuilder<T, R> |
Rebuilds only when selected value changes |
MultiReactiveBuilder |
Rebuilds when any of multiple properties change |
π‘ Contributions
- Want to help? Open an issue or submit a pull request!
- Improve the docs, add new features, or fix bugs
- Built with β€οΈ for the Flutter community
If you find this package helpful, please β the repo!
Libraries
- mvvm_core
- A lightweight, powerful MVVM state management library for Flutter.