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.
Install / Use
/learn @Circuids/FairyREADME
A lightweight MVVM framework for Flutter with strongly-typed reactive data binding, commands, and dependency injection - no code generation required.
Simplicity over complexity - Clean APIs, minimal boilerplate, zero dependencies.
📖 Table of Contents
- Installation
- Quick Start
- Properties
- Commands
- Widgets
- Dependency Injection
- Advanced Features
- Utilities
- Best Practices
- Performance
- Testing
- Maintenance & Release Cadence
Features
- Few Widgets to Learn -
Bindfor data,Commandfor actions - Type-Safe - Strongly-typed with compile-time safety
- No Code Generation - Runtime-only, no build_runner
- Auto UI Updates - Data binding that just works
- Command Pattern - Actions with
canExecutevalidation and error handling - Dependency Injection - Global and scoped DI
- Lightweight - Zero external dependencies
Installation
dependencies:
fairy: ^3.0.1
Quick Start
// 1. Create ViewModel
class CounterViewModel extends ObservableObject {
final counter = ObservableProperty<int>(0);
late final incrementCommand = RelayCommand(() => counter.value++);
}
// 2. Provide ViewModel with FairyScope
void main() => runApp(
FairyScope(
viewModel: (_) => CounterViewModel(),
child: MyApp(),
),
);
// 3. Bind UI
Bind<CounterViewModel, int>(
bind: (vm) => vm.counter,
builder: (context, value, update) => Text('$value'),
)
Command<CounterViewModel>(
command: (vm) => vm.incrementCommand,
builder: (context, execute, canExecute, isRunning) =>
ElevatedButton(
onPressed: canExecute ? execute : null,
child: Text('Increment'),
),
)
Properties
Properties hold reactive state in your ViewModel. When a property's value changes, any bound UI automatically rebuilds.
ObservableProperty<T>
The primary reactive property type. Automatically notifies listeners when .value changes.
class UserViewModel extends ObservableObject {
final name = ObservableProperty<String>('');
final age = ObservableProperty<int>(0);
void updateName(String newName) {
name.value = newName; // UI automatically rebuilds
}
}
Key features:
- Deep equality for collections:
List,Map, andSetvalues are compared by contents - Two-way binding: When bound with
Bind, provides anupdatecallback for form inputs
ObservableProperty.list/map/set
Factory constructors for collections that support in-place mutations.
class TodoViewModel extends ObservableObject {
final todos = ObservableProperty.list<Todo>([]);
final cache = ObservableProperty.map<String, Data>({});
final tags = ObservableProperty.set<String>({});
void addTodo(Todo todo) {
todos.value.add(todo); // ✅ Triggers rebuild
cache.value[todo.id] = todo; // ✅ Triggers rebuild
}
}
| Constructor | Use Case |
|-------------|----------|
| ObservableProperty.list<T>() | Mutable lists with add/remove |
| ObservableProperty.map<K,V>() | Mutable maps with updates |
| ObservableProperty.set<T>() | Mutable sets with add/remove |
| ObservableProperty<List<T>>() | Immutable pattern (reassignment only) |
ComputedProperty<T>
Derived values that automatically recalculate when dependencies change.
class CartViewModel extends ObservableObject {
final price = ObservableProperty<double>(10.0);
final quantity = ObservableProperty<int>(2);
late final total = ComputedProperty<double>(
() => price.value * quantity.value,
[price, quantity], // Dependencies
this, // Parent for auto-disposal
);
}
Commands
Commands encapsulate actions with optional validation (canExecute) and error handling.
RelayCommand
Synchronous command for immediate actions.
late final incrementCommand = RelayCommand(
() => count.value++,
canExecute: () => count.value < 100,
);
AsyncRelayCommand
Asynchronous command with automatic isRunning state tracking.
late final fetchCommand = AsyncRelayCommand(
() async => data.value = await api.fetchItems(),
);
// fetchCommand.isRunning tracks loading state automatically
Parameterized Commands
Use .param<T>() factory methods for commands that need parameters.
// Sync with parameter
late final deleteCommand = RelayCommand.param<String>(
(id) => todos.value.removeWhere((t) => t.id == id),
canExecute: (id) => id.isNotEmpty,
);
// Async with parameter
late final loadCommand = AsyncRelayCommand.param<String>(
(userId) async => user.value = await api.fetchUser(userId),
);
Note:
.param<T>()factory methods are preferred. Direct constructors (RelayCommandWithParam<T>,AsyncRelayCommandWithParam<T>) are also available.
Tip:
AsyncRelayCommand.param<T>blocks concurrent execution —execute("B")is silently dropped whileexecute("A")is still running. This is fine for data-loading commands likeloadCommandabove, but can cause missed taps in selection-style commands. See Fast Command Actions for the fix.
Error Handling
late final saveCommand = AsyncRelayCommand(
_save,
onError: (e, stack) => error.value = 'Save failed: $e',
);
Dynamic canExecute
MyViewModel() {
_dispose = selected.propertyChanged(() {
deleteCommand.notifyCanExecuteChanged();
});
}
Widgets
Fairy provides two primary widgets: Bind for data and Command for actions.
Bind<TViewModel, TValue>
// Two-way binding
Bind<UserViewModel, String>(
bind: (vm) => vm.name, // Returns property
builder: (context, value, update) => TextField(
controller: TextEditingController(text: value),
onChanged: update,
),
)
// One-way binding
Bind<UserViewModel, String>(
bind: (vm) => vm.name.value, // Returns value
builder: (context, value, _) => Text(value),
)
Bind.viewModel
Auto-tracks all accessed properties.
Bind.viewModel<UserViewModel>(
builder: (context, vm) => Column(
children: [
Text(vm.firstName.value),
Text(vm.lastName.value),
],
),
)
Command<TViewModel>
Command<MyViewModel>(
command: (vm) => vm.saveCommand,
builder: (context, execute, canExecute, isRunning) {
if (isRunning) return CircularProgressIndicator();
return ElevatedButton(
onPressed: canExecute ? execute : null,
child: Text('Save'),
);
},
)
Command.param
Command.param<TodoViewModel, String>(
command: (vm) => vm.deleteCommand,
builder: (context, execute, canExecute, isRunning) => IconButton(
onPressed: canExecute(todoId) ? () => execute(todoId) : null,
icon: Icon(Icons.delete),
),
)
Dependency Injection
FairyScope - Widget-Scoped DI
FairyScope(
viewModel: (_) => ProfileViewModel(),
child: ProfilePage(),
)
// Access in widgets
final vm = Fairy.of<UserViewModel>(context);
FairyScopeLocator - Factory Dependency Access
The FairyScopeLocator passed to factory callbacks provides access to both global and scoped dependencies:
FairyScope(
viewModel: (locator) => ProfileViewModel(
api: locator.get<ApiService>(), // Global (FairyLocator)
appVM: locator.get<AppViewModel>(), // Parent scope
),
child: ProfilePage(),
)
// Multiple VMs can depend on each other
FairyScope(
viewModels: [
(_) => UserViewModel(),
(locator) => SettingsViewModel(
userVM: locator.get<UserViewModel>(), // Same scope
),
],
child: DashboardPage(),
)
Resolution order: Current scope → Parent scopes → FairyLocator
FairyLocator - Global DI
void main() {
FairyLocator.registerSingleton<ApiService>(ApiService());
runApp(MyApp());
}
FairyBridge - For Overlays
showDialog(
context: context,
builder: (_) => FairyBridge(
context: context,
child: AlertDialog(content: /* Bind widgets work here */),
),
);
Advanced Features
Deep Equality for Collections
ObservableProperty performs deep equality for List, Map, and Set - even nested collections:
tags.value = ['flutter', 'dart']; // No rebuild (same contents)
tags.value = ['flutter', 'dart', 'web']; // Rebuilds (different)
matrix.value = [[1, 2], [3, 4]]; // No rebuild (nested equality!)
Disable if needed: ObservableProperty<List>([], deepEquality: false)
Custom Type Equality
Override == for value-based equality:
class User {
final String id;
final String name;
@override
bool operator ==(Object other) => other is User && id == other.id;
@override
int get hashCode => id.hashCode;
}
Cross-ViewModel Communication
Use propertyChanged() to listen across ViewModels:
class DashboardViewModel extends ObservableObject {
final _userVM = UserViewModel();
VoidCallback? _listener;
DashboardViewModel() {
_listener = _userVM.name.propertyChanged(() {
print('User name changed: ${_userVM.name.value}');
});
}
@override
void dispose() {
_listener?.call();
_userVM.dispose();
super.dispose();
}
}
Or use ComputedProperty for derived state:
late final displayName = ComputedProperty<String>(
() => '${_userVM.name.value} (${_userVM.emai
