SkillAgentSearch skills...

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/Fairy
About this skill

Quality Score

0/100

Supported Platforms

Universal

README

<div align="center"> <img src="src/logo.png" alt="Fairy Logo" width="300"/>

pub package License: BSD-3-Clause Flutter

</div>

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

Features

  • Few Widgets to Learn - Bind for data, Command for 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 canExecute validation 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, and Set values are compared by contents
  • Two-way binding: When bound with Bind, provides an update callback 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 while execute("A") is still running. This is fine for data-loading commands like loadCommand above, 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
View on GitHub
GitHub Stars10
CategoryDevelopment
Updated1mo ago
Forks1

Languages

Dart

Security Score

95/100

Audited on Feb 21, 2026

No findings