Original README snapshot from the linked source repository. Visit the repository for the latest content.
Control: State Management for Flutter
A simple, flexible state management library for Flutter with built-in concurrency support.
Features
🎯 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
Installation
Add the following dependency to your pubspec.yaml file:
dependencies:
control: ^1.0.0
Quick Start
Basic Example
typedef CounterState = ({int count, bool idle});
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 ));
});
}
Concurrency Strategies
1. Concurrent (Default)
Operations execute in parallel without waiting for each other:
class MyController extends StateController <MyState > {
MyController () : super (initialState: MyState .initial ());
void operation1 () => handle (() async { ... });
void operation2 () => handle (() async { ... });
}
2. Sequential (with Mixin)
Operations execute one after another in FIFO order:
class MyController extends StateController <MyState >
with SequentialControllerHandler {
MyController () : super (initialState: MyState .initial ());
void operation1 () => handle (() async { ... });
void operation2 () => handle (() async { ... });
}
3. Droppable (with Mixin)
New operations are dropped if one is already running:
class MyController extends StateController <MyState >
with DroppableControllerHandler {
MyController () : super (initialState: MyState .initial ());
void operation1 () => handle (() async { ... });
void operation2 () => handle (() async { ... });
}
4. Custom (with Mutex)
Use Mutex directly for fine-grained control:
class MyController extends StateController <MyState > {
MyController () : super (initialState: MyState .initial ());
final _criticalMutex = Mutex ();
final _batchMutex = Mutex ();
void criticalOperation () => _criticalMutex.synchronize (
() => handle (() async { ... }),
);
void batchOperation () => _batchMutex.synchronize (
() => handle (() async { ... }),
);
void fastOperation () => handle (() async { ... });
}
Return Values from Operations
The handle() method is generic and can return values:
class UserController extends StateController <UserState > {
UserController (this .api) : super (initialState: UserState .initial ());
final UserApi api;
Future <User > fetchUser (String id) => handle<User >(() async {
final user = await api.getUser (id);
setState (state.copyWith (user: user, loading: false ));
return user;
});
Future <bool > updateUser (User user) => handle<bool >(() async {
try {
await api.updateUser (user);
setState (state.copyWith (user: user));
return true ;
} catch (e) {
return false ;
}
});
}
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.
Usage in Flutter
Inject Controller
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 (),
),
);
}
Consume State
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),
),
);
}
Use ValueListenable
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' ),
),
)
Advanced Features
Error Handling
The handle() method provides built-in error handling:
void riskyOperation () => handle (
() async {
throw Exception ('Something went wrong' );
},
error: (error, stackTrace) async {
print ('Error: $error ' );
},
done: () async {
print ('Operation completed' );
},
name: 'riskyOperation' ,
);
Observer Pattern
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 ());
}
Mutex
Use Mutex for custom synchronization:
final mutex = Mutex ();
await mutex.synchronize (() async {
});
final unlock = await mutex.lock ();
try {
if (someCondition) {
unlock ();
return ;
}
} finally {
unlock ();
}
if (mutex.locked) {
print ('Mutex is currently locked' );
}
Migration from 0.x to 1.0.0
See MIGRATION.md for detailed migration guide.
Key changes:
Remove base from controller classes
ConcurrentControllerHandler is deprecated (remove it)
Controllers are concurrent by default
Use Mutex for custom concurrency patterns
Best Practices
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
Advanced Usage
UI Feedback with Callbacks
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},
);
}
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' ),
)
Interactive Dialogs During Processing
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 {
final session = await api.login (email, password);
if (session.requires2FA) {
final smsCode = await requestSmsCode ();
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 },
);
}
ElevatedButton (
onPressed: () => controller.login (
email,
password,
requestSmsCode: () async {
final code = await showDialog<String >(
context: context,
builder: (context) => SmsCodeDialog (),
);
return code ?? '' ;
},
),
child: const Text ('Login' ),
)
Debugging and Observability
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) {
print ('START | ${context.controller.name} .${context.name} ' );
print ('META | ${context.meta} ' );
final stopwatch = Stopwatch ()..start ();
context.done.whenComplete (() {
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 ) {
Sentry .addBreadcrumb (Breadcrumb (
message: '${controller.name} .${context.name} ' ,
data: context.meta,
level: SentryLevel .error,
));
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;
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} ' );
}
}
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
Examples
See example/ directory for complete examples:
Basic counter
Advanced concurrency patterns
Error handling
Custom observers
Coverage
Changelog
Refer to the Changelog to get all release notes.
Maintainers
Funding
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!
License
MIT