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 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: ^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 the Command widget 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:

  1. Current FairyScope - ViewModels registered in the nearest scope
  2. Parent FairyScopes - ViewModels from ancestor scopes (walking up the tree)
  3. FairyLocator - Global singleton registry
  4. 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 parent is provided)
  • When propertyChanged() or canExecuteChanged() is called directly, it registers a listener with the ChangeNotifier
  • 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. 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