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!