Pub Actions Status Coverage License: MIT Linter GitHub stars
A simple, flexible state management library for Flutter with built-in concurrency support.
- 🎯 Simple API - Easy to learn and use
- 🔄 Flexible Concurrency - Sequential, concurrent, or droppable operation handling
- 🛡️ Type Safe - Full type safety with Dart's type system
- 🔍 Observable - Built-in observer for debugging and logging
- 🧪 Well Tested - Comprehensive test coverage
- 📦 Lightweight - Minimal dependencies
- 🔧 Customizable - Use Mutex for custom concurrency patterns
Add the following dependency to your pubspec.yaml file:
dependencies: control: ^1.0.0
/// Counter state typedef CounterState = ({int count, bool idle}); /// Counter controller - concurrent by default class CounterController extends StateController<CounterState> { CounterController({CounterState? initialState}) : super(initialState: initialState ?? (idle: true, count: 0)); void increment() => handle(() async { setState((idle: false, count: state.count)); await Future<void>.delayed(const Duration(milliseconds: 500)); setState((idle: true, count: state.count + 1)); }); void decrement() => handle(() async { setState((idle: false, count: state.count)); await Future<void>.delayed(const Duration(milliseconds: 500)); setState((idle: true, count: state.count - 1)); }); }
Operations execute in parallel without waiting for each other:
class MyController extends StateController<MyState> { MyController() : super(initialState: MyState.initial()); // These operations run concurrently void operation1() => handle(() async { ... }); void operation2() => handle(() async { ... }); }
Operations execute one after another in FIFO order:
class MyController extends StateController<MyState> with SequentialControllerHandler { MyController() : super(initialState: MyState.initial()); // These operations run sequentially void operation1() => handle(() async { ... }); void operation2() => handle(() async { ... }); }
New operations are dropped if one is already running:
class MyController extends StateController<MyState> with DroppableControllerHandler { MyController() : super(initialState: MyState.initial()); // If operation1 is running, operation2 is dropped void operation1() => handle(() async { ... }); void operation2() => handle(() async { ... }); }
Use Mutex directly for fine-grained control:
class MyController extends StateController<MyState> { MyController() : super(initialState: MyState.initial()); final _criticalMutex = Mutex(); final _batchMutex = Mutex(); // Sequential critical operations void criticalOperation() => _criticalMutex.synchronize( () => handle(() async { ... }), ); // Sequential batch operations (different queue) void batchOperation() => _batchMutex.synchronize( () => handle(() async { ... }), ); // Concurrent fast operations void fastOperation() => handle(() async { ... }); }
The handle() method is generic and can return values:
class UserController extends StateController<UserState> { UserController(this.api) : super(initialState: UserState.initial()); final UserApi api; /// Fetch user and return the user object Future<User> fetchUser(String id) => handle<User>(() async { final user = await api.getUser(id); setState(state.copyWith(user: user, loading: false)); return user; // Type-safe return value }); /// Update user and return success status Future<bool> updateUser(User user) => handle<bool>(() async { try { await api.updateUser(user); setState(state.copyWith(user: user)); return true; } catch (e) { return false; } }); } // Usage final user = await controller.fetchUser('123'); print('Fetched: ${user.name}'); final success = await controller.updateUser(updatedUser); if (success) { print('User updated successfully'); }
Note: With DroppableControllerHandler, dropped operations return null instead of executing.
Use ControllerScope to provide controller to widget tree:
class App extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp( home: ControllerScope<CounterController>( CounterController.new, child: const CounterScreen(), ), ); }
Use StateConsumer to rebuild widgets when state changes:
class CounterScreen extends StatelessWidget { @override Widget build(BuildContext context) => Scaffold( body: StateConsumer<CounterController, CounterState>( builder: (context, state, _) => Text('Count: ${state.count}'), ), floatingActionButton: FloatingActionButton( onPressed: () => context.controllerOf<CounterController>().increment(), child: Icon(Icons.add), ), ); }
Convert state to ValueListenable for granular updates:
ValueListenableBuilder<bool>( valueListenable: controller.select((state) => state.idle), builder: (context, isIdle, _) => ElevatedButton( onPressed: isIdle ? () => controller.increment() : null, child: Text('Increment'), ), )
The handle() method provides built-in error handling:
void riskyOperation() => handle( () async { // Your operation throw Exception('Something went wrong'); }, error: (error, stackTrace) async { // Handle error print('Error: $error'); }, done: () async { // Always called, even if error occurs print('Operation completed'); }, name: 'riskyOperation', // For debugging );
Monitor all controller events for debugging:
class MyObserver implements IControllerObserver { @override void onCreate(Controller controller) { print('Controller created: ${controller.name}'); } @override void onHandler(HandlerContext context) { print('Handler started: ${context.name}'); } @override void onStateChanged<S extends Object>( StateController<S> controller, S prevState, S nextState, ) { print('State changed: $prevState -> $nextState'); } @override void onError(Controller controller, Object error, StackTrace stackTrace) { print('Error in ${controller.name}: $error'); } @override void onDispose(Controller controller) { print('Controller disposed: ${controller.name}'); } } void main() { Controller.observer = MyObserver(); runApp(MyApp()); }
Use Mutex for custom synchronization:
final mutex = Mutex(); // Method 1: synchronize (automatic unlock) await mutex.synchronize(() async { // Critical section }); // Method 2: lock/unlock (manual control) final unlock = await mutex.lock(); try { // Critical section if (someCondition) { unlock(); return; // Early exit } // More code } finally { unlock(); } // Check if locked if (mutex.locked) { print('Mutex is currently locked'); }
See MIGRATION.md for detailed migration guide.
Key changes:
- Remove
basefrom controller classes ConcurrentControllerHandleris deprecated (remove it)- Controllers are concurrent by default
- Use
Mutexfor custom concurrency patterns
-
Choose the right concurrency strategy:
- Default (concurrent) for independent operations
- Sequential for operations that must complete in order
- Droppable for operations that should cancel if busy
- Custom Mutex for complex scenarios
-
Use
handle()for all async operations:- Automatic error catching
- Observer notifications
- Proper disposal handling
-
Keep state immutable:
- Use records or immutable classes for state
- Always create new state instances
-
Dispose controllers:
- Controllers are automatically disposed by
ControllerScope - Manual disposal only needed for manually created controllers
- Controllers are automatically disposed by
Use error and done callbacks to provide user feedback through SnackBars, dialogs, or notifications:
class UserController extends StateController<UserState> { UserController(this.api) : super(initialState: UserState.initial()); final UserApi api; Future<User?> updateProfile( User user, { void Function(User user)? onSuccess, void Function(Object error)? onError, }) => handle<User>( () async { final updatedUser = await api.updateUser(user); setState(state.copyWith(user: updatedUser)); onSuccess?.call(updatedUser); return updatedUser; }, error: (error, stackTrace) async { onError?.call(error); }, name: 'updateProfile', meta: {'userId': user.id}, ); } // Usage in UI ElevatedButton( onPressed: () => controller.updateProfile( updatedUser, onSuccess: (user) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Profile updated: ${user.name}')), ); }, onError: (error) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Error: $error'), backgroundColor: Colors.red, ), ); }, ), child: const Text('Update Profile'), )
Add interactive dialogs in the middle of processing for user input:
class AuthController extends StateController<AuthState> { AuthController(this.api) : super(initialState: AuthState.initial()); final AuthApi api; Future<bool?> login( String email, String password, { required Future<String> Function() requestSmsCode, }) => handle<bool>( () async { // Step 1: Initial login final session = await api.login(email, password); // Step 2: Check if 2FA is required if (session.requires2FA) { // Request SMS code from user via dialog final smsCode = await requestSmsCode(); // Step 3: Verify SMS code await api.verify2FA(session.id, smsCode); } setState(state.copyWith(isAuthenticated: true)); return true; }, error: (error, stackTrace) async { setState(state.copyWith(error: error.toString())); }, name: 'login', meta: {'email': email, 'requires2FA': true}, ); } // Usage in UI ElevatedButton( onPressed: () => controller.login( email, password, requestSmsCode: () async { // Show dialog and wait for user input final code = await showDialog<String>( context: context, builder: (context) => SmsCodeDialog(), ); return code ?? ''; }, ), child: const Text('Login'), )
Use name and meta parameters for debugging, logging, and integration with error tracking services like Sentry or Crashlytics:
class ControllerObserver implements IControllerObserver { const ControllerObserver(); @override void onHandler(HandlerContext context) { // Log operation start with metadata print('START | ${context.controller.name}.${context.name}'); print('META | ${context.meta}'); final stopwatch = Stopwatch()..start(); context.done.whenComplete(() { // Log operation completion with duration stopwatch.stop(); print('DONE | ${context.controller.name}.${context.name} | ' 'duration: ${stopwatch.elapsed}'); }); } @override void onError(Controller controller, Object error, StackTrace stackTrace) { final context = Controller.context; if (context != null) { // Send breadcrumbs to Sentry/Crashlytics Sentry.addBreadcrumb(Breadcrumb( message: '${controller.name}.${context.name}', data: context.meta, level: SentryLevel.error, )); // Report error with full context Sentry.captureException( error, stackTrace: stackTrace, hint: Hint.withMap({ 'controller': controller.name, 'operation': context.name, 'metadata': context.meta, }), ); } } @override void onStateChanged<S extends Object>( StateController<S> controller, S prevState, S nextState, ) { final context = Controller.context; // Log state changes with operation context if (context != null) { print('STATE | ${controller.name}.${context.name} | ' '$prevState -> $nextState'); print('META | ${context.meta}'); } } @override void onCreate(Controller controller) { print('CREATE | ${controller.name}'); } @override void onDispose(Controller controller) { print('DISPOSE | ${controller.name}'); } } // Setup in main void main() { Controller.observer = const ControllerObserver(); runApp(const App()); }
Benefits of using name and meta:
- Debugging: Easily track which operation is executing
- Logging: Add context to logs for better traceability
- Profiling: Measure operation duration and performance
- Error tracking: Send rich context to Sentry/Crashlytics
- Analytics: Track user actions with metadata
- Breadcrumbs: Build execution trail for debugging crashes
See example/ directory for complete examples:
- Basic counter
- Advanced concurrency patterns
- Error handling
- Custom observers
Refer to the Changelog to get all release notes.
If you want to support the development of our library, there are several ways you can do it:
We appreciate any form of support, whether it's a financial donation or just a star on GitHub. It helps us to continue developing and improving our library. Thank you for your support!