mvvm_core 1.1.1 copy "mvvm_core: ^1.1.1" to clipboard
mvvm_core: ^1.1.1 copied to clipboard

Simple yet powerful MVVM state management for Flutter. Reactive properties, async handling, collections, and DevTools integration.

MVVM Core #

pub package License: MIT Flutter

A simple yet powerful MVVM state management library for Flutter. Built on Flutter's native primitives with zero external dependencies.

MVVM Architecture
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),
          ),
        ),
      ],
    );
  }
}

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 #

  1. Enable the MVVM Core DevTools Extension:

    • In your Flutter app, navigate to Flutter DevTools
    • Go to Settings (DevTools Extension Icon)
    • Enable the "MVVM Core" extension
  2. Override debugFillProperties in 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));
      }
    }
    
  3. Open DevTools and look for the MVVM Core tab!

    DevTools Extension


๐Ÿงช 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

GitHub stars     Pub likes

If you find this package helpful, please โญ the repo!

3
likes
160
points
19
downloads

Documentation

API reference

Publisher

verified publisherrutvik.dev

Weekly Downloads

Simple yet powerful MVVM state management for Flutter. Reactive properties, async handling, collections, and DevTools integration.

Repository (GitHub)
View/report issues

Topics

#state-management #mvvm #reactive #viewmodel #architecture

License

MIT (license)

Dependencies

flutter

More

Packages that depend on mvvm_core