view_model 0.8.0-dev.8 copy "view_model: ^0.8.0-dev.8" to clipboard
view_model: ^0.8.0-dev.8 copied to clipboard

The missing ViewModel in Flutter. Light & Pure.

ViewModel Logo

view_model #

The missing ViewModel in Flutter

Pub Version Codecov (with branch)

ChangeLog

English Doc | 中文文档

Thank Miolin for transferring the permission of the view_model package to me.


Design Philosophy #

ViewModel is designed specifically for UI logic and interaction, not just data flow.

Unlike general-purpose state management solutions (like Riverpod, which treats everything as a Provider), view_model focuses on the Presentation Layer.

  • For UI: It manages the lifecycle of the UI (Pause/Resume), handles user interactions, and converts data into UI state.
  • Not for Data: Complex data caching, combination, and transformation should be handled by the Repository/Data Layer (where Riverpod/Signals shines).
  • Collaboration: ViewModels depend on each other to "coordinate actions" (e.g., "User logged out -> Clear Cart UI"), rather than just "deriving data".

Use view_model to build a smart, lifecycle-aware UI layer, and leave the heavy data lifting to the data layer.


Quick Start #

  • Watch: watchViewModel<T>() / watchCachedViewModel<T>()
  • Read: readViewModel<T>() / readCachedViewModel<T>()
  • Global: ViewModel.readCached<T>() / maybeReadCached<T>()
  • Recycle: recycleViewModel(vm)
  • Effects: listen(onChanged) / listenState / listenStateSelect
  • Dependencies: watchCachedViewModel<T>() / readCachedViewModel<T>() in ViewModel

Reuse One Instance #

  • Key: set key() in factory → all widgets share the same instance
  • Tag: set tag() → bind newest instance via watchCachedViewModel(tag)
  • Any param: pass any Object as key/tag (e.g. 'user:$id')

Important

When using custom objects as key or tag, ensure they properly implement == operator and hashCode for correct cache lookup. You can use third-party libraries like equatable or freezed to simplify this implementation.

final f = DefaultViewModelFactory<UserViewModel>(
  builder: () => UserViewModel(userId: id),
  key: 'user:$id',
);
final vm1 = watchViewModel(factory: f);
final vm2 = watchCachedViewModel<UserViewModel>(key: 'user:$id'); // same

Basic Usage #

Adding Dependencies #

First, add view_model to your project's pubspec.yaml file:

dependencies:
  flutter:
    sdk: flutter
  view_model: ^0.7.0 # Please use the latest version

Creating a ViewModel #

Inherit ViewModel to define business logic. Treat fields as state and call notifyListeners() to trigger UI updates.

import 'package:view_model/view_model.dart';
import 'package:flutter/foundation.dart'; // For debugPrint

class MySimpleViewModel extends ViewModel {
  String _message = "Initial Message";
  int _counter = 0;

  String get message => _message;

  int get counter => _counter;

  void updateMessage(String newMessage) {
    update(() {
      _message = newMessage;
    });
  }

  void incrementCounter() {
    update(() {
      _counter++;
    });
  }

  @override
  void dispose() {
    // Clean up resources here, such as closing StreamControllers, etc.
    debugPrint('MySimpleViewModel disposed');
    super.dispose();
  }
}

Creating a ViewModelFactory #

ViewModelFactory is responsible for instantiating ViewModel. Each ViewModel type typically requires a corresponding Factory.

import 'package:view_model/view_model.dart';
// Assume MySimpleViewModel is defined as above

class MySimpleViewModelFactory with ViewModelFactory<MySimpleViewModel> {
  @override
  MySimpleViewModel build() {
    // Return a new MySimpleViewModel instance
    return MySimpleViewModel();
  }
}

Using ViewModel in Widgets #

Mix ViewModelStateMixin into your State and call watchViewModel to bind and rebuild when notifyListeners() is invoked. Lifecycle is handled for you.

import 'package:flutter/material.dart';
import 'package:view_model/view_model.dart';

// Assume MySimpleViewModel and MySimpleViewModelFactory are defined

class MyPage extends StatefulWidget {
  const MyPage({super.key});

  @override
  State<MyPage> createState() => _MyPageState();
}

class _MyPageState extends State<MyPage>
    with ViewModelStateMixin<MyPage> {
  // 1. Mix in the Mixin
  late final MySimpleViewModel simpleVM;

  @override
  void initState() {
    super.initState();
    // 2. Use watchViewModel to create and get the ViewModel
    // When MyPage is built for the first time, the build() method of MySimpleViewModelFactory will be called to create an instance.
    // When MyPage is disposed, if this viewModel has no other listeners, it will also be disposed.
    simpleVM =
        watchViewModel<MySimpleViewModel>(factory: MySimpleViewModelFactory());
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(simpleVM.message)), // Directly access the ViewModel's properties
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text('Button pressed: ${simpleVM.counter} times'), // Access the ViewModel's properties
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: () {
                simpleVM.updateMessage("Message Updated!"); // Call the ViewModel's method
              },
              child: const Text('Update Message'),
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => simpleVM.incrementCounter(), // Call the ViewModel's method
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

Alternative: ViewModelBuilder (no mixin required)

// Example: Using ViewModelBuilder without mixing ViewModelStateMixin
ViewModelBuilder<MySimpleViewModel>(
  factory: MySimpleViewModelFactory(),
  builder: (vm) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        Text(vm.message),
        const SizedBox(height: 8),
        ElevatedButton(
          onPressed: () => vm.updateMessage("Message Updated!"),
          child: const Text('Update Message'),
        ),
      ],
    );
  },
)

Listening to a cached instance: CachedViewModelBuilder

// Example: Using CachedViewModelBuilder to bind to an existing instance
CachedViewModelBuilder<MySimpleViewModel>(
  shareKey: "shared-key", // or: tag: "shared-tag"
  builder: (vm) {
    return Row(
      children: [
        Expanded(child: Text(vm.message)),
        IconButton(
          onPressed: () => vm.incrementCounter(),
          icon: const Icon(Icons.add),
        ),
      ],
    );
  },
)

Side‑effects with listeners #

// In the initState of State or another appropriate method
late VoidCallback _disposeViewModelListener;

@override
void initState() {
  super.initState();

  // Get the ViewModel instance (usually obtained once in initState or via a getter)
  final myVm = watchViewModel<MySimpleViewModel>(factory: MySimpleViewModelFactory());

  _disposeViewModelListener = myVm.listen(onChanged: () {
    print('MySimpleViewModel called notifyListeners! Current counter: ${myVm.counter}');
    // For example: ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Action performed!')));
  });
}

@override
void dispose() {
  _disposeViewModelListener(); // Clean up the listener to prevent memory leaks
  super.dispose();
}

Visibility pause/resume #

doc

Pause/Resume Lifecycle #

The pause/resume lifecycle is managed by ViewModelPauseProviders. By default, PageRoutePauseProvider, TickModePauseProvider and AppPauseProvider handle pausing/resuming based on route visibility and app lifecycle events, respectively.

Detailed Parameter Explanation #

ViewModelFactory #

Factory creates and identifies instances. Use key() to share one instance, use tag() to group/discover.

Method/Property Type Optional Description
build() T ❌ Must implement The factory method to create a ViewModel instance. Typically, constructor parameters are passed here.
key() Object? ✅ Optional Provides a unique identifier for the ViewModel. ViewModels with the same key will be automatically shared (recommended for cross-widget/page sharing).
tag() Object? Add a tag for ViewModel instance. get tag by viewModel.tag. and it's used by find ViewModel by watchViewModel(tag:tag).

Note: If you use a custom key object, implement == and hashCode to ensure correct cache lookup.

class MyViewModelFactory with ViewModelFactory<MyViewModel> {
  // Your custom parameters, usually passed to MyViewModel
  final String initialName;

  MyViewModelFactory({required this.initialName});

  @override
  MyViewModel build() {
    return MyViewModel(name: initialName);
  }

  /// The key for sharing the ViewModel. The key is unique, and only one ViewModel instance will be created for the same key.
  /// If the key is null, no sharing will occur.
  @override
  Object? key() => "user-profile";
}

ViewModel Lifecycle #

  • watch* / read* bind ViewModel to a State
  • When no widget watchers remain, the instance auto‑disposes

ViewModel → ViewModel dependencies #

Inside a ViewModel, use readCachedViewModel (non‑reactive) or watchCachedViewModel (reactive) to access other ViewModels.

Key Concept: Although it looks like ViewModels depend on each other, they actually all attach to the same Widget's State. The dependency structure is flat (only 1 level deep) - all ViewModels are managed by the same State object. ViewModels only have listening relationships with each other, not nested dependencies.

  • Lifecycle: When HostVM accesses SubVM via watchCachedViewModel, both attach to the same Widget State.
  • Cleanup: When the State is disposed, all attached ViewModels are disposed together (if no other widgets watch them).
  • Coordination: ViewModels listen to each other to coordinate actions, staying in the same UI layer.
class UserProfileViewModel extends ViewModel {
  void loadData() {
    // One-time access, no listening
    final authVM = readCachedViewModel<AuthViewModel>();
    if (authVM?.isLoggedIn == true) {
      _fetchProfile(authVM!.userId);
    }
  }

  void setupReactiveAuth() {
    // Reactive access - automatically updates when auth changes
    final authVM = watchCachedViewModel<AuthViewModel>();
    // This ViewModel will be notified when authVM changes
  }

  void manualListening() {
    final authVM = readCachedViewModel<AuthViewModel>();
    // You can also manually listen to any ViewModel
    authVM?.listen(() {
      // Custom listening logic
      _handleAuthChange(authVM);
    });
  }
}

Stateful ViewModel (StateViewModel<S>) #

Use StateViewModel<S> when you prefer an immutable state object and updates via setState(newState). Supports listenState(prev, next) for state‑specific reactions.

Note

By default, StateViewModel uses identical() to compare state instances (comparing object references, not content). This means setState() will trigger a rebuild only when you provide a new state instance. You can customize this comparison behavior globally via the equals function in ViewModel.initialize() (see Initialization section).

Defining the State Class #

First, you need to define a state class. It is strongly recommended that this class is immutable, typically achieved by providing a copyWith method.

// example: lib/my_counter_state.dart
import 'package:flutter/foundation.dart';

@immutable // Recommended to mark as immutable
class MyCounterState {
  final int count;
  final String statusMessage;

  const MyCounterState({this.count = 0, this.statusMessage = "Ready"});

  MyCounterState copyWith({int? count, String? statusMessage}) {
    return MyCounterState(
      count: count ?? this.count,
      statusMessage: statusMessage ?? this.statusMessage,
    );
  }

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
          other is MyCounterState &&
              runtimeType == other.runtimeType &&
              count == other.count &&
              statusMessage == other.statusMessage;

  @override
  int get hashCode => count.hashCode ^ statusMessage.hashCode;

  @override
  String toString() => 'MyCounterState{count: $count, statusMessage: $statusMessage}';
}

Creating a Stateful ViewModel #

Inherit from StateViewModel<S>, where S is the type of the state class you defined.

// example: lib/my_counter_view_model.dart
import 'package:view_model/view_model.dart';
import 'package:flutter/foundation.dart';
import 'my_counter_state.dart'; // Import the state class

class MyCounterViewModel extends StateViewModel<MyCounterState> {
  // The constructor must initialize the state via super
  MyCounterViewModel({required MyCounterState initialState}) : super(state: initialState);

  void increment() {
    // Use setState to update the state, which will automatically handle notifyListeners
    setState(state.copyWith(count: state.count + 1, statusMessage: "Incremented"));
  }

  void decrement() {
    if (state.count > 0) {
      setState(state.copyWith(count: state.count - 1, statusMessage: "Decremented"));
    } else {
      setState(state.copyWith(statusMessage: "Cannot decrement below zero"));
    }
  }

  void reset() {
    // You can directly replace the old state with a new State instance
    setState(const MyCounterState(count: 0, statusMessage: "Reset"));
  }

  @override
  void dispose() {
    debugPrint('Disposed MyCounterViewModel with state: $state');
    super.dispose();
  }
}

In StateViewModel, you update the state by calling setState(newState). This method replaces the old state with the new one and automatically notifies all listeners.

Creating a ViewModelFactory #

Create a corresponding Factory for your StateViewModel.

// example: lib/my_counter_view_model_factory.dart
import 'package:view_model/view_model.dart';
import 'my_counter_state.dart';
import 'my_counter_view_model.dart';

class MyCounterViewModelFactory with ViewModelFactory<MyCounterViewModel> {
  final int initialCount;

  MyCounterViewModelFactory({this.initialCount = 0});

  @override
  MyCounterViewModel build() {
    // Create and return the ViewModel instance in the build method, passing the initial state
    return MyCounterViewModel(
        initialState: MyCounterState(count: initialCount, statusMessage: "Initialized"));
  }
}

Using Stateful ViewModel in Widgets #

Using a stateful ViewModel in a StatefulWidget is very similar to using a stateless ViewModel, with the main difference being that you can directly access viewModel.state to obtain the current state object.

// example: lib/my_counter_page.dart
import 'package:flutter/material.dart';
import 'package:view_model/view_model.dart';
import 'my_counter_view_model.dart';
import 'my_counter_view_model_factory.dart';
// MyCounterState will be referenced internally by MyCounterViewModel

class MyCounterPage extends StatefulWidget {
  const MyCounterPage({super.key});

  @override
  State<MyCounterPage> createState() => _MyCounterPageState();
}

class _MyCounterPageState extends State<MyCounterPage>
    with ViewModelStateMixin<MyCounterPage> {
  late final MyCounterViewModel counterVM;

  @override
  void initState() {
    super.initState();
    counterVM = watchViewModel<MyCounterViewModel>(
        factory: MyCounterViewModelFactory(initialCount: 10)); // You can pass an initial value
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Stateful ViewModel Counter')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'Count: ${counterVM.state.count}', // Directly access the state's properties
              style: Theme
                  .of(context)
                  .textTheme
                  .headlineMedium,
            ),
            const SizedBox(height: 8),
            Text(
              'Status: ${counterVM.state.statusMessage}', // Access other properties of the state
              style: Theme
                  .of(context)
                  .textTheme
                  .titleMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: Column(
        mainAxisAlignment: MainAxisAlignment.end,
        crossAxisAlignment: CrossAxisAlignment.end,
        children: [
          FloatingActionButton(
            onPressed: () => counterVM.increment(),
            tooltip: 'Increment',
            child: const Icon(Icons.add),
          ),
          const SizedBox(height: 8),
          FloatingActionButton(
            onPressed: () => counterVM.decrement(),
            tooltip: 'Decrement',
            child: const Icon(Icons.remove),
          ),
          const SizedBox(height: 8),
          FloatingActionButton.extended(
            onPressed: () => counterVM.reset(),
            tooltip: 'Reset',
            icon: const Icon(Icons.refresh),
            label: const Text("Reset"),
          ),
        ],
      ),
    );
  }
}

DefaultViewModelFactory Quick Factory #

When to Use #

For simple cases, use DefaultViewModelFactory<T> to avoid writing a custom factory.

Usage #


final factory = DefaultViewModelFactory<MyViewModel>(
  builder: () => MyViewModel(),
  isSingleton: true, // optional
);

Parameters #

  • builder: Function to create the ViewModel instance.
  • key: Custom key for singleton instance sharing.
  • tag: Custom tag for identifying the ViewModel.
  • isSingleton: Whether to use singleton mode. This is just a convenient way to set a unique key for you. Note that the priority is lower than the key parameter.

Example #


final factory = DefaultViewModelFactory<CounterViewModel>(
  builder: () => CounterViewModel(),
);
final sharedFactory = DefaultViewModelFactory<CounterViewModel>(
  builder: () => CounterViewModel(),
  key: 'global-counter',
);

Initialization #

Before using the view_model package, it's recommended to perform a one-time initialization in your main function. This allows you to configure global settings for the entire application.

void main() {
  // Configure ViewModel global settings
  ViewModel.initialize(
    config: ViewModelConfig(
      // Enable or disable logging for all ViewModels.
      // It's useful for debugging state changes and lifecycle events.
      // Defaults to false.
      isLoggingEnabled: true,
      
      // Provide a custom global function to determine if two states are equal.
      // This is used by `StateViewModel` and `listenStateSelect` with selectors to decide
      // whether to trigger a rebuild.
      // If not set, `StateViewModel` uses `identical()` and `listenStateSelect` uses `==`.
      equals: (previous, current) {
        // Example: Use a custom `isEqual` method for deep comparison
        return identical(previous, current);
      },
    ),
    // You can also register global lifecycle observers here
    lifecycles: [
      GlobalLifecycleObserver(),
    ],
  );
  
  runApp(const MyApp());
}

Configuration Options:

  • isLoggingEnabled: A bool that toggles logging for all ViewModel instances. When enabled, you'll see outputs for state changes, creations, and disposals, which is helpful during development.
  • equals: A function bool Function(dynamic previous, dynamic current) that provides a global strategy for state comparison. It affects:
    • StateViewModel: Determines if the new state is the same as the old one.
    • ViewModel.listen: Decides if the selected value has changed.
  • lifecycles: A list of ViewModelLifecycle observers that listen to lifecycle events (e.g., onCreate, onDispose) for all ViewModels. This is useful for global logging, analytics, or other cross-cutting concerns.

Goloabl ViewModel Lifecycle #

/// Abstract interface for observing ViewModel lifecycle events.
///
/// Implement this interface to receive callbacks when ViewModels are created,
/// watched, unwatched, or disposed. This is useful for logging, analytics,
/// debugging, or other cross-cutting concerns.
///
/// Example:
/// ```dart
/// class LoggingLifecycle extends ViewModelLifecycle {
///   @override
///   void onCreate(ViewModel viewModel, InstanceArg arg) {
///     print('ViewModel created: ${viewModel.runtimeType}');
///   }
///
///   @override
///   void onDispose(ViewModel viewModel, InstanceArg arg) {
///     print('ViewModel disposed: ${viewModel.runtimeType}');
///   }
/// }
/// ```
abstract class ViewModelLifecycle {
  /// Called when a ViewModel instance is created.
  ///
  /// Parameters:
  /// - [viewModel]: The newly created ViewModel
  /// - [arg]: Creation arguments including key, tag, and other metadata
  void onCreate(ViewModel viewModel, InstanceArg arg) {}

  /// Called when a new watcher is added to a ViewModel.
  ///
  /// Parameters:
  /// - [viewModel]: The ViewModel being watched
  /// - [arg]: Instance arguments
  /// - [newWatchId]: Unique identifier for the new watcher
  void onAddWatcher(ViewModel viewModel, InstanceArg arg, String newWatchId) {}

  /// Called when a watcher is removed from a ViewModel.
  ///
  /// Parameters:
  /// - [viewModel]: The ViewModel being unwatched
  /// - [arg]: Instance arguments
  /// - [removedWatchId]: Unique identifier for the removed watcher
  void onRemoveWatcher(
      ViewModel viewModel, InstanceArg arg, String removedWatchId) {}

  /// Called when a ViewModel is disposed.
  ///
  /// Parameters:
  /// - [viewModel]: The ViewModel being disposed
  /// - [arg]: Instance arguments
  void onDispose(ViewModel viewModel, InstanceArg arg) {}
}

Value‑level Rebuilds #

  • For fine-grained UI updates, use ValueNotifier with ValueListenableBuilder.
final title = ValueNotifier('Hello');
ValueListenableBuilder(
  valueListenable: title,
  builder: (_, v, __) => Text(v),
);
  • For more dynamic scenarios, ObservableValue and ObserverBuilder offer more flexibility.
// shareKey for share value cross any widget
final observable = ObservableValue<int>(0, shareKey: share);
observable.value = 20;

ObserverBuilder<int>(observable: observable, 
        builder: (v) {
          return Text(v.toString());
        },
      )
  • To rebuild only when a specific value within a StateViewModel changes, use StateViewModelValueWatcher.
class MyWidget extends State with ViewModelStateMixin {
  const MyWidget({super.key});

  late final MyViewModel stateViewModel;

  @override
  void initState() {
    super.initState();
    stateViewModel = readViewModel<MyViewModel>(
      factory: MyViewModelFactory(),
    );
  }

  @override
  Widget build(BuildContext context) {
    // Watch value changes on `stateViewModel` and rebuild only when `name` or `age` changes.
    return StateViewModelValueWatcher<MyViewModel>(
      stateViewModel: stateViewModel,
      selectors: [(state) => state.name, (state) => state.age],
      builder: (state) {
        return Text('Name: \${state.name}, Age: \${state.age}');
      },
    );
  }
}

DevTools Extension #

Enable the DevTools extension for real‑time ViewModel monitoring.

create devtools_options.yaml in root directory of project.

description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
  - view_model: true