
A lightweight MVVM framework for Flutter that provides strongly-typed, reactive data binding. Fairy combines reactive properties, command patterns, and dependency injection with minimal boilerplate.
Design Philosophy
Simplicity Over Complexity - Fairy is built around the principle that state management should be simple and intuitive. With just a few widgets and types, you have everything you need for most use cases. This simplicity-first approach is reflected throughout the entire library design, making it easy to learn, easy to use, and easy to maintain.
Features
- 🎓 Few Widgets to Learn:
Bind
for data,Command
for actions - covers almost everything - ✨ No Code Generation: Runtime-only implementation, no build_runner required
- 🎯 Type-Safe Binding: Strongly-typed reactive properties and commands with compile-time safety
- 🔄 Automatic UI Updates: Data binding that automatically updates your UI when state changes
- ⚡ Command Pattern: Encapsulate actions with built-in
canExecute
validation - 🏗️ Dependency Injection: Both global singleton and widget-scoped DI patterns
- 🧩 Minimal Boilerplate: Clean, intuitive API that gets out of your way
- 📦 Lightweight: Small footprint with zero external dependencies (except Flutter)
Quick Start
Installation
Add Fairy to your pubspec.yaml
:
dependencies:
fairy: ^1.3.0
Basic Example
import 'package:fairy/fairy.dart';
import 'package:flutter/material.dart';
// 1. Create a ViewModel extending ObservableObject
class CounterViewModel extends ObservableObject {
final counter = ObservableProperty<int>(0);
final multiplier = ObservableProperty<int>(2);
late final incrementCommand = RelayCommand(() => counter.value++);
late final addCommand = RelayCommandWithParam<int>((amount) => counter.value += amount);
// Properties and commands auto-disposed by super.dispose()
}
// 2. Use FairyScope to provide the ViewModel
// Recommended: At app root for app-wide ViewModels
void main() {
runApp(
FairyScope(
viewModel: (_) => CounterViewModel(),
child: MyApp(),
),
);
}
// Or anywhere in your widget tree (page-level, feature-level, etc.)
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: FairyScope(
viewModel: (_) => CounterViewModel(),
child: CounterPage(),
),
);
}
}
// 3. Bind your UI to ViewModel properties and commands
class CounterPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Option 1: Explicit binding (recommended for single properties)
Bind<CounterViewModel, int>(
selector: (vm) => vm.counter,
builder: (context, value, update) => Text('$value'),
),
// Option 2: Auto Binding for multiple properties
Bind.viewModel<CounterViewModel>(
builder: (context, vm) => Text('Count: ${vm.counter.value} × ${vm.multiplier.value}'),
),
// Command binding (non-parameterized)
Command<CounterViewModel>(
command: (vm) => vm.incrementCommand,
builder: (context, execute, canExecute, isRunning) {
return ElevatedButton(
onPressed: canExecute ? execute : null,
child: Text('Increment'),
);
},
),
// Command binding (parameterized)
Command.param<CounterViewModel, int>(
command: (vm) => vm.addCommand,
parameter: () => 5,
builder: (context, execute, canExecute, isRunning) {
return ElevatedButton(
onPressed: canExecute ? execute : null,
child: Text('Add 5'),
);
},
),
],
),
),
);
}
}
Core Concepts
1. ObservableObject - ViewModel Base Class
Your ViewModels extend ObservableObject
:
class UserViewModel extends ObservableObject {
final name = ObservableProperty<String>('');
final age = ObservableProperty<int>(0);
// ✅ Properties auto-disposed by super.dispose()
// No manual disposal needed!
}
Auto-Disposal: Properties and commands are automatically disposed when the parent ViewModel is disposed. See Best Practices for details.
2. ObservableProperty
Type-safe properties that notify listeners when their value changes:
class MyViewModel extends ObservableObject {
final counter = ObservableProperty<int>(0);
void increment() {
// Just modify the value - that's it!
// ObservableProperty automatically notifies listeners (like Bind widgets)
counter.value++;
}
void reset() {
counter.value = 0; // Automatic notification on change
}
}
Optional: Manual Change Subscription
You can manually subscribe to changes if needed (e.g., for logging, analytics, or side effects):
class MyViewModel extends ObservableObject {
final counter = ObservableProperty<int>(0);
late final VoidCallback disposePropertyChanges;
MyViewModel() {
// Optional: Subscribe to changes for side effects
disposePropertyChanges = counter.propertyChanged(() {
print('Counter changed: ${counter.value}');
// Maybe log analytics, trigger side effects, etc.
});
}
@override
void dispose() {
disposePropertyChanges(); // ⚠️ Always call to avoid memory leaks!
super.dispose(); // Auto-disposes counter
}
}
⚠️ Memory Leak Warning: Always capture and call the disposer returned by
propertyChanged()
. Failing to do so will cause memory leaks as the listener remains registered indefinitely. For UI binding, useBind
widgets which handle lifecycle automatically.
3. Commands - Action Encapsulation
Commands encapsulate actions with optional validation:
class MyViewModel extends ObservableObject {
final selectedItem = ObservableProperty<Item?>(null);
late final saveCommand = RelayCommand(_save);
late final deleteCommand = RelayCommand(
_delete,
canExecute: () => selectedItem.value != null,
);
late final VoidCallback disposePropertyChanges;
MyViewModel() {
// Refresh command's canExecute when selectedItem changes
disposePropertyChanges = selectedItem.propertyChanged(() {
deleteCommand.notifyCanExecuteChanged();
});
}
@override
void dispose() {
disposePropertyChanges();
super.dispose(); // Auto-disposes selectedItem, saveCommand, deleteCommand
}
void _save() {
// Save logic - Command handles execution automatically
}
void _delete() {
// Delete logic
}
}
Optional: Manual Command Change Subscription
Commands support manual subscription to canExecute
state changes for advanced scenarios (e.g., analytics, debugging):
class MyViewModel extends ObservableObject {
final userName = ObservableProperty<String>('');
late final saveCommand = RelayCommand(
_save,
canExecute: () => userName.value.isNotEmpty,
);
late final VoidCallback disposePropertyChanges;
late final VoidCallback disposeCommandChanges;
MyViewModel() {
// Keep command's canExecute in sync with userName
disposePropertyChanges = userName.propertyChanged(() {
saveCommand.notifyCanExecuteChanged();
});
// Optional: Subscribe to canExecute changes for side effects
disposeCommandChanges = saveCommand.canExecuteChanged(() {
print('Save enabled: ${saveCommand.canExecute}');
// Maybe update analytics, show hints, etc.
});
}
void _save() {
// Save logic
}
@override
void dispose() {
disposePropertyChanges();
disposeCommandChanges();
super.dispose(); // Auto-disposes userName and saveCommand
}
}
⚠️ Memory Leak Warning: Always capture disposers returned by
propertyChanged()
andcanExecuteChanged()
. Failing to call them will cause memory leaks. For UI binding, useCommand
widget which handles lifecycle automatically.
Async Commands
Async commands automatically track execution state with isRunning
, preventing concurrent execution and enabling easy loading indicators:
class MyViewModel extends ObservableObject {
late final fetchCommand = AsyncRelayCommand(_fetchData);
Future<void> _fetchData() async {
// fetchCommand.isRunning is automatically true
await api.getData();
// fetchCommand.isRunning automatically false
}
}
// In UI - isRunning automatically prevents double-clicks
Command<MyViewModel>(
command: (vm) => vm.fetchCommand,
builder: (context, execute, canExecute, isRunning) {
if (isRunning) return CircularProgressIndicator();
return ElevatedButton(
onPressed: execute,
child: Text('Fetch Data'),
);
},
)
Parameterized Commands
Commands that accept parameters (useful for item actions, delete operations, etc.):
class TodoViewModel extends ObservableObject {
final todos = ObservableProperty<List<Todo>>([]);
late final deleteTodoCommand = RelayCommandWithParam<String>(
(id) => todos.value = todos.value.where((t) => t.id != id).toList(),
canExecute: (id) => todos.value.any((t) => t.id == id),
);
}
// In UI - use Command.param:
Command.param<TodoViewModel, String>(
command: (vm) => vm.deleteTodoCommand,
parameter: () => todoId,
builder: (context, execute, canExecute, isRunning) {
return IconButton(
onPressed: canExecute ? execute : null,
icon: Icon(Icons.delete),
);
},
)
4. Data Binding with Bind
The Bind
widget handles reactive data binding. With just a few widgets (Bind
and Command
), you're covering almost all your UI binding needs.
Explicit Binding (Recommended)
Use Bind<TViewModel, TValue>
with an explicit selector for optimal performance:
// One-way binding (read-only)
Bind<UserViewModel, String>(
selector: (vm) => vm.name.value, // Returns String
builder: (context, value, update) {
return Text(value); // update is null
},
)
// Two-way binding (read-write)
Bind<UserViewModel, String>(
selector: (vm) => vm.name, // Returns ObservableProperty<String>
builder: (context, value, update) {
return TextField(
controller: TextEditingController(text: value),
onChanged: update, // update callback provided
);
},
)
Auto-Binding with Bind.viewModel
For multiple properties, use Bind.viewModel
which automatically tracks accessed properties and binds them. Fairy also provides Bind.viewModel2
, Bind.viewModel3
, and Bind.viewModel4
for tracking 2-4 specific ViewModels when you need explicit multi-ViewModel binding:
class UserViewModel extends ObservableObject {
final firstName = ObservableProperty<String>('John');
final lastName = ObservableProperty<String>('Doe');
final age = ObservableProperty<int>(30);
}
// Auto-Binding all accessed properties - no manual selectors needed!
Bind.viewModel<UserViewModel>(
builder: (context, vm) {
return Column(
children: [
Text('Name: ${vm.firstName.value} ${vm.lastName.value}'),
Text('Age: ${vm.age.value}'),
// All three properties automatically tracked!
// Widget rebuilds only when accessed properties change
],
);
},
)
When to use Bind.viewModel
(and its variants):
- Multiple related properties displayed together
- Complex UI with many data points
- Rapid prototyping and development
- When convenience outweighs micro-optimization
- Use
Bind.viewModel2/3/4
when you need to track specific multiple ViewModels explicitly
Performance Note: Explicit selectors are ~5-10% faster. The viewModel2/3/4
variants provide compile-time type safety when tracking multiple ViewModels.
5. Command Binding with Command
The Command
widget binds commands to UI elements:
Non-Parameterized Commands
Command<UserViewModel>(
command: (vm) => vm.saveCommand,
builder: (context, execute, canExecute, isRunning) {
return ElevatedButton(
onPressed: canExecute ? execute : null, // Auto-disabled
child: isRunning ? Text('Saving...') : Text('Save'),
);
},
)
Parameterized Commands with Command.param
When your command needs parameters, use a function that returns the parameter value for reactive evaluation:
Command.param<TodoViewModel, String>(
command: (vm) => vm.deleteTodoCommand,
parameter: () => todoId, // Function for reactive evaluation
builder: (context, execute, canExecute, isRunning) {
return IconButton(
onPressed: canExecute ? execute : null,
icon: Icon(Icons.delete),
);
},
)
For reactive parameters from controllers, wrap with ValueListenableBuilder
:
Bind<TodoViewModel, TextEditingController>(
selector: (vm) => vm.titleController,
builder: (context, controller, _) {
return ValueListenableBuilder<TextEditingValue>(
valueListenable: controller,
builder: (context, value, _) {
return Command.param<TodoViewModel, String>(
command: (vm) => vm.addTodoCommand,
parameter: () => value.text, // Reactive to text changes
builder: (context, execute, canExecute, isRunning) {
return ElevatedButton(
onPressed: canExecute ? execute : null,
child: Text('Add Todo'),
);
},
);
},
);
},
)
6. Dependency Injection
Fairy provides two powerful DI patterns that can be used together:
Scoped DI with FairyScope
FairyScope
provides widget-scoped ViewModels with automatic lifecycle management. It's flexible and can be used anywhere in your widget tree:
Note: ViewModels Registered Using FairyScope
is not tied to Build Context but Uses Widget Tree for Dependency Lifecycle Management, Therefore setting autoDispose: false
will keep the ViewModel alive until manually disposed. but by default it is true
.
At the app root (even above MaterialApp):
void main() {
runApp(
FairyScope(
viewModel: (_) => AppViewModel(),
child: MyApp(),
),
);
}
// Or wrap MaterialApp
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FairyScope(
viewModel: (_) => ThemeViewModel(),
child: MaterialApp(
home: HomePage(),
),
);
}
}
At page level:
FairyScope(
viewModel: (_) => ProfileViewModel(userId: widget.userId),
child: ProfilePage(),
)
Nested scopes (parent-child relationship):
FairyScope(
viewModel: (_) => ParentViewModel(),
child: Column(
children: [
FairyScope(
viewModel: (_) => ChildViewModel(),
child: ChildWidget(),
),
],
),
)
Multiple ViewModels in one scope:
FairyScope(
viewModels: [
(_) => UserViewModel(),
(_) => SettingsViewModel(),
(_) => NotificationViewModel(),
],
child: DashboardPage(),
)
Accessing ViewModels:
// In widgets
final userVM = Fairy.of<UserViewModel>(context);
final settingsVM = context.of<SettingsViewModel>();
// In ViewModels (dependency injection)
FairyLocator.instance.registerSingleton<ApiService>(ApiService());
FairyScope(
viewModels: [
(_) => UserViewModel(),
(locator) => UserViewModel(
api: locator.get<ApiService>(), // Access previously registered service
),
(locator) => SettingsViewModel(
userVM: locator.get<UserViewModel>(), // Access sibling VM
)
],
child: MyPage(),
)
Auto-disposal: By default, FairyScope automatically disposes ViewModels when removed from the tree. You can control this:
FairyScope(
viewModel: (_) => MyViewModel(),
autoDispose: true, // Default: auto-dispose when scope is removed
child: MyPage(),
)
FairyScope(
viewModel: (_) => SharedViewModel(),
autoDispose: false, // Keep alive, manual disposal required
child: MyPage(),
)
Global DI with FairyLocator
For app-wide singletons like services:
// Register in main()
void main() {
FairyLocator.instance.registerSingleton<ApiService>(ApiService());
// Register singleton
FairyLocator.instance.registerSingleton<AuthService>(AuthService());
// Lazy singleton registration
FairyLocator.instance.registerLazySingleton<DatabaseService>(() => DatabaseService());
// Async singleton registration
await FairyLocator.instance.registerSingletonAsync<ConfigService>(
() async => await ConfigService.load(),
);
// Register transient (new instance each time) or else we call factory registration
FairyLocator.instance.registerTransient<TempService>(() => TempService());
runApp(MyApp());
}
// Access anywhere
final api = FairyLocator.instance.get<ApiService>();
// Or use in FairyScope
// locator parameter provides access to registered services inside FairyScope
FairyScope(
viewModel: (locator) => ProfileViewModel(
api: locator.get<ApiService>(),
auth: locator.get<AuthService>(),
),
child: ProfilePage(),
)
// Cleanup (usually not needed for app-wide services)
FairyLocator.instance.unregister<ApiService>();
Resolution Order
Fairy.of<T>(context)
and the locator
parameter in FairyScope check:
- Current FairyScope - ViewModels registered in the nearest scope
- Parent FairyScopes - ViewModels from ancestor scopes (walking up the tree)
- FairyLocator - Global singleton registry
- Throws exception if not found
This design allows:
- ✅ Child ViewModels to access parent ViewModels
- ✅ Any ViewModel to access global services
- ✅ Proper scoping and lifecycle management
- ✅ Compile-time type safety
Note: The API follows Flutter's convention (e.g., Theme.of(context)
, MediaQuery.of(context)
) for familiar and idiomatic usage.
Bridging ViewModels to Overlays with FairyBridge
Problem: Overlays (dialogs, bottom sheets, menus) create separate widget trees that can't access parent FairyScopes through normal context lookup.
Solution: FairyBridge
widget captures the parent context's FairyScope and makes it available to the overlay's context.
class TodoListPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Todo List')),
body: TodoListView(),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddTodoDialog(context),
child: Icon(Icons.add),
),
);
}
void _showAddTodoDialog(BuildContext context) {
showDialog(
context: context,
builder: (_) => FairyBridge(
context: context, // Parent context with FairyScope
child: AlertDialog(
title: Text('Add Todo'),
content: Bind<TodoListViewModel, TextEditingController>(
selector: (vm) => vm.titleController,
builder: (context, controller, _) {
return ValueListenableBuilder<TextEditingValue>(
valueListenable: controller,
builder: (context, value, _) {
return TextField(
controller: controller,
decoration: InputDecoration(
labelText: 'Title',
hintText: 'Enter todo title',
),
);
},
);
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel'),
),
Bind<TodoListViewModel, TextEditingController>(
selector: (vm) => vm.titleController,
builder: (context, controller, _) {
return ValueListenableBuilder<TextEditingValue>(
valueListenable: controller,
builder: (context, value, _) {
return Command.param<TodoListViewModel, String>(
command: (vm) => vm.addTodoCommand,
parameter: () => value.text,
builder: (context, execute, canExecute, isRunning) {
return TextButton(
onPressed: canExecute ? execute : null,
child: Text('Add'),
);
},
);
},
);
},
),
],
),
),
);
}
}
What it does:
- Looks up parent context's FairyScope
- Creates an InheritedWidget that provides the same scope to overlay
Bind
andCommand
widgets inside overlay now work seamlessly- If no FairyScope found in parent, gracefully returns child (falls back to FairyLocator)
When to use:
- Dialogs (
showDialog
) - Bottom sheets (
showModalBottomSheet
,showBottomSheet
) - Menus (
showMenu
) - Any overlay that creates a new route or separate widget tree
When NOT needed:
- Regular navigation (
Navigator.push
) - new routes have access to parent context - Widgets within the same widget tree - normal context lookup works
Advanced Features
ComputedProperty - Automatic Derived Values
ComputedProperty
is a game-changer for managing derived state. It automatically recomputes when dependencies change, eliminating manual synchronization and making your ViewModels dramatically cleaner.
Why You'll Love It
Without ComputedProperty:
class UserViewModel extends ObservableObject {
final firstName = ObservableProperty<String>('John');
final lastName = ObservableProperty<String>('Doe');
String _fullName = 'John Doe';
String get fullName => _fullName;
late final VoidCallback disposeFirstNameChanges;
late final VoidCallback disposeLastNameChanges;
UserViewModel() {
// Manual listener setup - error-prone and verbose
disposeFirstNameChanges = firstName.propertyChanged(_updateFullName);
disposeLastNameChanges = lastName.propertyChanged(_updateFullName);
_updateFullName();
}
void _updateFullName() {
_fullName = '${firstName.value} ${lastName.value}';
onPropertyChanged(); // Easy to forget!
}
@override
void dispose() {
disposeFirstNameChanges();
disposeLastNameChanges();
super.dispose(); // Manual cleanup required (and easy to forget!)
}
}
With ComputedProperty:
class UserViewModel extends ObservableObject {
final firstName = ObservableProperty<String>('John');
final lastName = ObservableProperty<String>('Doe');
// That's it! Auto-updates, auto-caches, auto-disposes 🎉
late final fullName = ComputedProperty<String>(
() => '${firstName.value} ${lastName.value}',
[firstName, lastName],
this, // Required parent for automatic disposal
);
}
Real-World Examples
Shopping Cart with Chained Computations:
class CartViewModel extends ObservableObject {
final items = ObservableProperty<List<Item>>([]);
final taxRate = ObservableProperty<double>(0.08);
final discountCode = ObservableProperty<String?>('');
// Base calculation
late final subtotal = ComputedProperty<double>(
() => items.value.fold(0.0, (sum, item) => sum + item.price),
[items],
this,
);
// Depends on another computed property!
late final discount = ComputedProperty<double>(
() => discountCode.value == 'SAVE20' ? subtotal.value * 0.20 : 0.0,
[subtotal, discountCode],
this,
);
late final afterDiscount = ComputedProperty<double>(
() => subtotal.value - discount.value,
[subtotal, discount],
this,
);
late final tax = ComputedProperty<double>(
() => afterDiscount.value * taxRate.value,
[afterDiscount, taxRate],
this,
);
// Final total - automatically updates when ANYTHING changes!
late final total = ComputedProperty<double>(
() => afterDiscount.value + tax.value,
[afterDiscount, tax],
this,
);
}
Form Validation (Perfect for canExecute):
class LoginViewModel extends ObservableObject {
final email = ObservableProperty<String>('');
final password = ObservableProperty<String>('');
late final isEmailValid = ComputedProperty<bool>(
() => email.value.contains('@') && email.value.length > 5,
[email],
this,
);
late final isPasswordValid = ComputedProperty<bool>(
() => password.value.length >= 8,
[password],
this,
);
late final canSubmit = ComputedProperty<bool>(
() => isEmailValid.value && isPasswordValid.value,
[isEmailValid, isPasswordValid],
this,
);
// Use computed property in command validation
late final loginCommand = AsyncRelayCommand(
_login,
canExecute: () => canSubmit.value,
);
Future<void> _login() async {
// Login logic
}
}
Key Benefits
✅ Zero Maintenance - No manual updates, listeners are managed automatically
✅ Performance - Smart caching, only recomputes when dependencies actually change
✅ Composable - Computed properties can depend on other computed properties
✅ Type-Safe - Strongly-typed with compile-time safety
✅ No Memory Leaks - Auto-disposal handles all cleanup
✅ Clean Code - Declarative dependencies eliminate boilerplate
✅ Testable - Pure functions make unit testing trivial
Performance Note
ComputedProperty is highly optimized:
- Only recomputes when dependencies actually notify (not just on access)
- Benefits from ObservableProperty's built-in equality checking
- Cached values mean no redundant calculations
- Efficient for complex dependency chains
Deep Equality for Collections
By default, ObservableProperty
performs recursive deep equality for List
, Map
, and Set
, comparing contents instead of references - even for nested collections! This works automatically without any configuration.
class TodoViewModel extends ObservableObject {
// Deep equality for collections (enabled by default)
final tags = ObservableProperty<List<String>>(['flutter', 'dart']);
// Works with nested collections too!
final matrix = ObservableProperty<List<List<int>>>([[1, 2], [3, 4]]);
void updateTags() {
// No rebuild - same contents
tags.value = ['flutter', 'dart'];
// Rebuilds - different contents
tags.value = ['flutter', 'dart', 'web'];
// Nested collections work automatically!
matrix.value = [[1, 2], [3, 4]]; // No rebuild (same nested contents)
matrix.value = [[1, 2], [3, 5]]; // Rebuilds (different nested contents)
}
}
Handles arbitrary nesting depth:
// 3 levels deep: List<Map<String, List<int>>>
final deepData = ObservableProperty([
{'a': [1, 2], 'b': [3, 4]},
{'c': [5, 6], 'd': [7, 8]},
]);
// Same data, different objects - no rebuild! 🎉
deepData.value = [
{'a': [1, 2], 'b': [3, 4]},
{'c': [5, 6], 'd': [7, 8]},
];
// Changed deep nested value - rebuilds correctly
deepData.value = [
{'a': [1, 2], 'b': [3, 4]},
{'c': [5, 6], 'd': [7, 9]}, // Changed 8 to 9
];
Disable deep equality if you need reference equality:
final items = ObservableProperty<List<Item>>(
[],
deepEquality: false, // Use reference equality
);
// Now rebuilds on every assignment (different reference)
items.value = [...items.value];
Using the Equals
utility class directly:
import 'package:fairy/fairy.dart';
// Direct comparison utilities (with deep equality)
bool same = Equals.listEquals([1, 2], [1, 2]); // true
bool nested = Equals.listEquals([[1, 2]], [[1, 2]]); // true (nested!)
bool maps = Equals.mapEquals({'a': 1}, {'a': 1}); // true
bool sets = Equals.setEquals({1, 2}, {2, 1}); // true (order doesn't matter)
// Deep collection equality for any type
bool complex = Equals.deepCollectionEquals(
{'users': [{'name': 'Alice'}]},
{'users': [{'name': 'Alice'}]},
); // true!
// Hash codes for using collections as map keys
int hash = Equals.listHash([[1, 2], [3, 4]]);
Custom Type Equality
Custom types automatically use their ==
operator - no special configuration needed:
class User {
final String id;
final String name;
User(this.id, this.name);
// Override == to define custom equality (optional)
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is User && id == other.id;
@override
int get hashCode => id.hashCode;
}
// Works automatically - uses User's == operator
final user = ObservableProperty<User>(User('1', 'Alice'));
user.value = User('1', 'Bob'); // No rebuild (same id)
user.value = User('2', 'Alice'); // Rebuilds (different id)
For custom types containing collections (optional optimization):
Deep equality works automatically for collections at any level. However, if you want to optimize equality checks for frequently-compared custom types, you can optionally override ==
:
class Project {
final String name;
final List<String> tasks;
Project(this.name, this.tasks);
// OPTIONAL: Override == for optimized comparisons
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Project &&
name == other.name &&
Equals.listEquals(tasks, other.tasks);
@override
int get hashCode => name.hashCode ^ Equals.listHash(tasks);
}
// Without overriding ==, ObservableProperty will use reference equality
// for custom types, which works fine but may trigger more rebuilds
final project = ObservableProperty<Project>(
Project('Work', ['Task 1'])
);
project.value = Project('Work', ['Task 1']); // Rebuilds (different reference)
// With overridden ==, it compares by value
// project.value = Project('Work', ['Task 1']); // No rebuild (same value)
Key Point: You only need to override ==
for custom types if you want value-based equality instead of reference equality. The collections inside will be compared deeply either way when you do override ==
.
Best Practices
1. Auto-Disposal
ObservableProperty, ComputedProperty, and Commands are automatically disposed when the parent ViewModel is disposed:
class UserViewModel extends ObservableObject {
final userName = ObservableProperty<String>('');
final age = ObservableProperty<int>(0);
late final saveCommand = RelayCommand(_save);
late final fullInfo = ComputedProperty<String>(
() => '${userName.value}, age ${age.value}',
[userName, age],
);
void _save() { /* ... */ }
// ✅ All properties and commands auto-disposed by super.dispose()
// No manual disposal needed!
}
Exception: Nested ViewModels require manual disposal:
class ParentViewModel extends ObservableObject {
final data = ObservableProperty<String>('');
// Nested ViewModels require manual disposal
late final childVM = ChildViewModel(); // ⚠️ Manual disposal required
@override
void dispose() {
childVM.dispose(); // Must manually dispose nested ViewModels
super.dispose(); // Auto-disposes properties and commands
}
}
This prevents double-disposal issues when nested ViewModels are shared or managed externally.
2. Refresh Commands on Dependency Changes
When a command's canExecute
depends on other properties, refresh the command when those properties change:
class MyViewModel extends ObservableObject {
final selectedItem = ObservableProperty<Item?>(null);
late final deleteCommand = RelayCommand(
_delete,
canExecute: () => selectedItem.value != null,
);
late final editCommand = RelayCommand(
_edit,
canExecute: () => selectedItem.value != null,
);
VoidCallback? disposePropertyChanges;
MyViewModel() {
// When canExecute depends on other state
disposePropertyChanges = selectedItem.propertyChanged(() {
deleteCommand.notifyCanExecuteChanged();
editCommand.notifyCanExecuteChanged();
});
}
void _delete() { /* ... */ }
void _edit() { /* ... */ }
@override
void dispose() {
disposePropertyChanges?.call();
super.dispose(); // selectedItem and commands auto-disposed
}
}
3. Always Capture Disposers from Manual Listener Calls ⚠️
WARNING: While properties and commands are auto-disposed, manual listeners are NOT. Not capturing the disposer returned by propertyChanged()
or canExecuteChanged()
will cause memory leaks!
// ❌ MEMORY LEAK: Disposer is ignored
viewModel.propertyChanged(() {
print('changed');
});
command.canExecuteChanged(() {
print('canExecute changed');
});
// Listeners stay in memory forever, even after widget disposal!
// ✅ CORRECT: Capture and call disposers
class _MyWidgetState extends State<MyWidget> {
late VoidCallback disposePropertyChanges;
late VoidCallback disposeCommandChanges;
@override
void initState() {
super.initState();
final vm = Fairy.of<MyViewModel>(context);
// Store the disposers
disposePropertyChanges = vm.counter.propertyChanged(() {
setState(() {});
});
disposeCommandChanges = vm.saveCommand.canExecuteChanged(() {
setState(() {});
});
}
@override
void dispose() {
disposePropertyChanges(); // Clean up listeners
disposeCommandChanges();
super.dispose();
}
}
// ✅ BEST: Use Bind/Command widgets (handle lifecycle automatically)
Bind<MyViewModel, int>(
selector: (vm) => vm.counter,
builder: (context, value, update) => Text('$value'),
)
Command<MyViewModel>(
command: (vm) => vm.saveCommand,
builder: (context, execute, canExecute, isRunning) =>
ElevatedButton(onPressed: canExecute ? execute : null, child: Text('Save')),
)
Why memory leaks still occur with manual listeners:
- Auto-disposal only handles property/command cleanup
- When
propertyChanged()
orcanExecuteChanged()
is called directly, it registers a listener - The listener stays registered until explicitly removed via the disposer
- Without calling the disposer, the listener (and any objects it captures) remain in memory
- This is especially problematic if the
ViewModel
outlives the widget (e.g., global singleton)
Best practice: Use Bind
or Command
widgets for 99% of UI scenarios. Only use propertyChanged()
or canExecuteChanged()
directly in StatefulWidget
when you have a specific reason, and always capture and call the disposer.
4. Use Scoped DI for Page-Level ViewModels
// ✅ Good: Scoped ViewModel auto-disposed
FairyScope(
viewModel: (_) => UserProfileViewModel(userId: widget.userId),
child: UserProfilePage(),
)
// ❌ Avoid: Manual lifecycle management
class _PageState extends State<Page> {
late final vm = UserProfileViewModel();
@override
void dispose() {
vm.dispose(); // Easy to forget!
super.dispose();
}
}
5. Use Global DI for App-Wide Services
// Register in main()
void main() {
FairyLocator.instance.registerSingleton<ApiService>(ApiService());
FairyLocator.instance.registerSingleton<AuthService>(AuthService());
runApp(MyApp());
}
6. Choose the Right Binding Approach
For single properties: Use explicit Bind<TViewModel, TValue>
for optimal performance:
// ✅ Best for single properties
Bind<MyVM, int>(
selector: (vm) => vm.counter, // Returns ObservableProperty<int>
builder: (context, value, update) => Text('$value'),
)
For multiple properties: Use Bind.viewModel
for convenience with excellent selective efficiency:
// ✅ Best for multiple properties
Bind.viewModel<UserViewModel>(
builder: (context, vm) {
return Text('${vm.firstName.value} ${vm.lastName.value}');
// Both properties automatically bounded!
},
)
Avoid one-way binding: Returning raw values requires manual change notification:
// ❌ More complex: One-way binding requires ViewModel.onPropertyChanged()
Bind<MyVM, int>(
selector: (vm) => vm.counter.value, // Returns int
builder: (context, value, _) => Text('$value'),
)
Example
See the example directory for a complete counter app demonstrating:
- MVVM architecture
- Reactive properties
- Command pattern with canExecute
- Data and command binding
- Scoped dependency injection
Testing
Fairy is thoroughly tested with 522 tests passing, covering all core functionality including observable properties, commands, auto-disposal, dependency injection, widget binding, deep equality, and overlay scenarios.
Fairy is designed for testability:
test('increment updates counter', () {
final vm = CounterViewModel();
expect(vm.counter.value, 0);
vm.incrementCommand.execute();
expect(vm.counter.value, 1);
vm.dispose(); // Auto-disposes all properties and commands
});
Widget tests work seamlessly with FairyScope
and both Bind
variants:
testWidgets('counter increments on button tap', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: FairyScope(
viewModel: (_) => CounterViewModel(),
child: CounterPage(),
),
),
);
await tester.tap(find.byType(ElevatedButton));
await tester.pumpAndSettle();
expect(find.text('1'), findsOneWidget);
});
testWidgets('Bind.viewModel rebuilds on property change', (tester) async {
final vm = UserViewModel();
await tester.pumpWidget(
MaterialApp(
home: FairyScope(
viewModel: (_) => vm,
child: Bind.viewModel<UserViewModel>(
builder: (context, vm) => Text('${vm.firstName.value}'),
),
),
),
);
expect(find.text('John'), findsOneWidget);
vm.firstName.value = 'Jane';
await tester.pump();
expect(find.text('Jane'), findsOneWidget);
});
Architecture Guidelines
ViewModel
✅ DO: Business logic, state (ObservableProperty), commands, derived values (ComputedProperty)
❌ DON'T: Reference BuildContext/widgets, navigation, UI logic, styling
View (Widgets)
✅ DO: Use Bind
/Command
widgets, handle navigation, declarative composition
❌ DON'T: Business logic, data validation, direct state modification
Binding Patterns
✅ DO:
- Single property:
selector: (vm) => vm.property.value
- Tuples:
selector: (vm) => (vm.a.value, vm.b.value)
← All.value
! - Two-way:
selector: (vm) => vm.property
(returns ObservableProperty)
❌ DON'T:
- Mix in tuples:
(vm.a.value, vm.b)
← TypeError! - Create new instances in selectors ← Infinite rebuilds!
Commands
✅ DO: Call notifyCanExecuteChanged()
when conditions change, use AsyncRelayCommand
for async
❌ DON'T: Long operations in sync commands, forget to update canExecute
Dependency Injection
✅ DO: FairyScope
for pages/features, FairyLocator
for app-wide services, FairyBridge
for overlays
❌ DON'T: Register ViewModels globally, manually dispose FairyScope ViewModels
Comparison to Other Patterns
Feature | Fairy | Provider | Riverpod | GetX | BLoC |
---|---|---|---|---|---|
Code Generation | ❌ | ❌ | ✅ | ❌ | ❌ |
Type Safety | ✅ | ✅ | ✅ | ⚠️ | ✅ |
Boilerplate | Low | Low | Medium | Low | High |
Learning Curve | Low | Low | Medium | Low | Medium |
Command Pattern | ✅ | ❌ | ❌ | ❌ | ❌ |
Two-Way Binding | ✅ | ❌ | ❌ | ✅ | ❌ |
Auto-Disposal | ✅ | ⚠️ | ✅ | ✅ | ⚠️ |
License
BSD 3-Clause License - see LICENSE file for details.
Contributing
Contributions are welcome! Please read the contributing guidelines before submitting PRs.
Libraries
- fairy
- Fairy - A lightweight MVVM framework for Flutter