Fairy
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.
Features
- ✨ 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
canExecutevalidation - 🏗️ 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: ^0.5.0+2
Basic Example
import 'package:fairy/fairy.dart';
import 'package:flutter/material.dart';
// 1. Create a ViewModel extending ObservableObject
class CounterViewModel extends ObservableObject {
// Recommended: Use extension methods (cleaner syntax, auto-passes parent)
final counter = observableProperty<int>(0);
late final incrementCommand = relayCommand(() => counter.value++);
// Alternative: Explicit syntax (shows what extension methods do internally)
// final counter = ObservableProperty<int>(0, parent: this);
// late final RelayCommand incrementCommand;
// CounterViewModel() {
// incrementCommand = RelayCommand(execute: () => counter.value++, parent: this);
// }
// Properties and commands auto-disposed by super.dispose()
}
// 2. Use FairyScope to provide the ViewModel
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: [
// Data binding - automatically updates when counter changes
Bind<CounterViewModel, int>(
selector: (vm) => vm.counter,
builder: (context, value, update) => Text('$value'),
),
// Command binding - automatically disabled when canExecute is false
Command<CounterViewModel>(
command: (vm) => vm.incrementCommand,
builder: (context, execute, canExecute) {
return ElevatedButton(
onPressed: canExecute ? execute : null,
child: Text('Increment'),
);
},
),
],
),
),
);
}
}
Core Concepts
1. ObservableObject - ViewModel Base Class
Your ViewModels extend ObservableObject:
class UserViewModel extends ObservableObject {
// Recommended: Use extension methods (clean and simple)
final name = observableProperty<String>('');
final age = observableProperty<int>(0);
// Alternative: Explicit syntax
// final name = ObservableProperty<String>('', parent: this);
// final age = ObservableProperty<int>(0, parent: this);
// ✅ Properties auto-disposed by super.dispose()
// No manual disposal needed!
}
Auto-Disposal: Properties and commands are automatically disposed when the parent ViewModel is disposed. Use extension methods like observableProperty(), relayCommand(), etc., for cleaner syntax. See Best Practices for details.
2. ObservableProperty
Type-safe properties that notify listeners when their value changes:
class MyViewModel extends ObservableObject {
// Recommended: Extension method (clean and simple)
final counter = observableProperty<int>(0);
// Alternative: Explicit syntax
// final counter = ObservableProperty<int>(0, parent: this);
void someMethod() {
// Modify value
counter.value = 42;
// Listen to changes (returns disposer function)
final dispose = counter.propertyChanged(() {
print('Counter changed: ${counter.value}');
});
// Later: remove listener
dispose(); // ⚠️ Always call this to avoid memory leaks!
}
}
⚠️ 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. See Best Practices section for details.
3. Commands - Action Encapsulation
Commands encapsulate actions with optional validation:
class MyViewModel extends ObservableObject {
final selectedItem = observableProperty<Item?>(null);
// Recommended: Extension methods (cleaner)
late final saveCommand = relayCommand(_save);
late final deleteCommand = relayCommand(
_delete,
canExecute: () => selectedItem.value != null,
);
// Alternative: Explicit syntax (same result)
// late final RelayCommand saveCommand;
// late final RelayCommand deleteCommand;
// MyViewModel() {
// saveCommand = RelayCommand(execute: _save, parent: this);
// deleteCommand = RelayCommand(
// execute: _delete,
// canExecute: () => selectedItem.value != null,
// parent: this,
// );
// }
late final VoidCallback _disposer;
MyViewModel() {
// Refresh command when dependencies change
_disposer = selectedItem.propertyChanged(() {
deleteCommand.notifyCanExecuteChanged();
});
}
@override
void dispose() {
_disposer();
super.dispose(); // Auto-disposes properties and commands
}
void _save() {
// Save logic
}
void _delete() {
// Delete logic
}
}
Async Commands
For asynchronous operations with automatic isRunning state:
class MyViewModel extends ObservableObject {
// Recommended: Extension method
late final fetchCommand = asyncRelayCommand(_fetchData);
// Alternative: Explicit syntax
// late final AsyncRelayCommand fetchCommand;
// MyViewModel() {
// fetchCommand = AsyncRelayCommand(execute: _fetchData, parent: this);
// }
Future<void> _fetchData() async {
// fetchCommand.isRunning is automatically true
await api.getData();
// fetchCommand.isRunning automatically false
}
}
Parameterized Commands
Commands that accept parameters:
class MyViewModel extends ObservableObject {
final counter = observableProperty<int>(0);
// Recommended: Extension method
late final addValueCommand = relayCommandWithParam<int>(
(value) => counter.value += value,
canExecute: (value) => value > 0,
);
// Alternative: Explicit syntax
// late final RelayCommandWithParam<int> addValueCommand;
// MyViewModel() {
// addValueCommand = RelayCommandWithParam<int>(
// execute: (value) => counter.value += value,
// canExecute: (value) => value > 0,
// parent: this,
// );
// }
}
// In UI:
CommandWithParam<MyViewModel, int>(
command: (vm) => vm.addValueCommand,
parameter: 5,
builder: (context, execute, canExecute) {
return ElevatedButton(
onPressed: canExecute ? execute : null,
child: Text('Add 5'),
);
},
)
Listening to Command Changes
Commands support listening to canExecute state changes, similar to how properties work:
class MyViewModel extends ObservableObject {
final userName = observableProperty<String>('');
// Using extension method
late final saveCommand = relayCommand(
_save,
canExecute: () => userName.value.isNotEmpty,
);
VoidCallback? _commandDisposer;
MyViewModel() {
// Listen to canExecute changes
_commandDisposer = saveCommand.canExecuteChanged(() {
print('Save command canExecute changed: ${saveCommand.canExecute}');
});
}
void _save() {
// Save logic
}
@override
void dispose() {
_commandDisposer?.call();
super.dispose(); // Auto-disposes userName and saveCommand
}
}
⚠️ Memory Leak Warning: Always capture the disposer returned by
canExecuteChanged(). Failing to call it will cause memory leaks. For UI binding, use theCommandwidget which handles this automatically.
4. Data Binding
The Bind widget automatically detects one-way vs two-way binding:
Two-Way Binding
When selector returns ObservableProperty<T>, you get two-way binding with an update callback:
Bind<UserViewModel, String>(
selector: (vm) => vm.name, // Returns ObservableProperty<String>
builder: (context, value, update) {
return TextField(
controller: TextEditingController(text: value),
onChanged: update, // update is non-null for two-way binding
);
},
)
One-Way Binding
When selector returns raw T, you get 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 for one-way binding
},
)
Note: For one-way binding with raw values, the ViewModel must explicitly call onPropertyChanged() when values change. It's often simpler to use two-way binding even for read-only scenarios.
5. Command Binding
The Command widget binds commands to UI elements:
Command<UserViewModel>(
command: (vm) => vm.saveCommand,
builder: (context, execute, canExecute) {
return ElevatedButton(
onPressed: canExecute ? execute : null, // Auto-disabled
child: saveCommand.isRunning
? CircularProgressIndicator()
: Text('Save'),
);
},
)
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.
Advanced Features
ComputedProperty
Derived properties that depend on other ObservableProperties:
class MyViewModel extends ObservableObject {
// Recommended: Extension methods
final firstName = observableProperty<String>('John');
final lastName = observableProperty<String>('Doe');
late final fullName = computedProperty<String>(
() => '${firstName.value} ${lastName.value}',
[firstName, lastName],
);
// Alternative: Explicit syntax
// final firstName = ObservableProperty<String>('John', parent: this);
// final lastName = ObservableProperty<String>('Doe', parent: this);
// late final ComputedProperty<String> fullName;
// MyViewModel() {
// fullName = ComputedProperty<String>(
// () => '${firstName.value} ${lastName.value}',
// [firstName, lastName],
// parent: this,
// );
// }
}
Custom Value Equality
ObservableProperty uses != for equality checking. For custom types, override the == operator:
class User {
final String id;
final String name;
User(this.id, this.name);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is User && runtimeType == other.runtimeType && id == other.id;
@override
int get hashCode => id.hashCode;
}
final user = ObservableProperty<User>(
User('1', 'Alice'),
parent: this,
);
Best Practices
1. Auto-Disposal with Parent Parameter
ObservableProperty, ComputedProperty, and Commands are automatically disposed when created using extension methods or the parent parameter:
class UserViewModel extends ObservableObject {
// Recommended: Extension methods (clean and simple)
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],
);
// Alternative: Explicit syntax
// final userName = ObservableProperty<String>('', parent: this);
// final age = ObservableProperty<int>(0, parent: this);
// late final RelayCommand saveCommand;
// late final ComputedProperty<String> fullInfo;
// UserViewModel() {
// saveCommand = RelayCommand(execute: _save, parent: this);
// fullInfo = ComputedProperty<String>(
// () => '${userName.value}, age ${age.value}',
// [userName, age],
// parent: this,
// );
// }
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 {
// Properties auto-disposed (using extension method)
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 {
// Using extension methods
final selectedItem = observableProperty<Item?>(null);
late final deleteCommand = relayCommand(
_delete,
canExecute: () => selectedItem.value != null,
);
late final editCommand = relayCommand(
_edit,
canExecute: () => selectedItem.value != null,
);
VoidCallback? _selectedItemDisposer;
MyViewModel() {
// When canExecute depends on other state
_selectedItemDisposer = selectedItem.propertyChanged(() {
deleteCommand.notifyCanExecuteChanged();
editCommand.notifyCanExecuteChanged();
});
}
void _delete() { /* ... */ }
void _edit() { /* ... */ }
@override
void dispose() {
_selectedItemDisposer?.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 _disposePropertyListener;
late VoidCallback _disposeCommandListener;
@override
void initState() {
super.initState();
final vm = Fairy.of<MyViewModel>(context);
// Store the disposers
_disposePropertyListener = vm.counter.propertyChanged(() {
setState(() {});
});
_disposeCommandListener = vm.saveCommand.canExecuteChanged(() {
setState(() {});
});
}
@override
void dispose() {
_disposePropertyListener(); // Clean up listeners
_disposeCommandListener();
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) =>
ElevatedButton(onPressed: canExecute ? execute : null, child: Text('Save')),
)
Why memory leaks still occur with manual listeners:
- Auto-disposal only handles property/command cleanup (when
parentis provided) - When
propertyChanged()orcanExecuteChanged()is called directly, it registers a listener with theChangeNotifier - 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
ViewModeloutlives 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. Prefer Two-Way Binding for Simplicity
Even for read-only scenarios, using two-way binding (returning ObservableProperty) is simpler than one-way binding (returning raw values):
// ✅ Simpler: Two-way binding
Bind<MyVM, int>(
selector: (vm) => vm.counter, // Returns ObservableProperty<int>
builder: (context, value, update) => Text('$value'),
)
// ❌ 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 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 with parent parameter
});
Widget tests work seamlessly with FairyScope:
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);
});
Architecture Guidelines
ViewModel Responsibilities
✅ DO:
- Contain business logic
- Manage state with ObservableProperty
- Expose commands for user actions
- Coordinate with services/repositories
❌ DON'T:
- Reference widgets or BuildContext
- Perform navigation
- Contain UI logic or styling
View Responsibilities
✅ DO:
- Purely declarative widget composition
- Bind to ViewModel properties and commands
- Handle navigation
❌ DON'T:
- Contain business logic
- Directly modify application state
- Perform data validation
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 | ✅ | ❌ | ❌ | ✅ | ❌ |
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