Leulit Flutter Action Manager

A lightweight, type-safe action dispatcher for Flutter applications. Works seamlessly across all layers: UI, domain, data, and services.

pub package License: MIT

✨ Features

  • Type-safe with Dart enums (no string typos!)
  • TypedAction<T>: compile-time type validation — mismatches are caught by the compiler, not at runtime
  • Zero external dependencies (only Flutter/Dart)
  • Universal: Works in widgets, controllers, services, repositories
  • Reactive: StreamSubscription support for easy cleanup
  • Debuggable: Built-in introspection and statistics
  • Memory-safe: Proper handler cleanup
  • Incremental migration: TypedAction<T> coexists with existing enum-based code

🚀 Installation

Add to your pubspec.yaml:

dependencies:
  leulit_flutter_actionmanager: ^5.0.0

Then run:

flutter pub get

📚 Basic Usage

1. Define your actions

enum AppAction {
  userLoggedIn,
  userLoggedOut,
  dataLoaded,
  networkError,
  zoomIn,
  zoomOut,
}

2. Dispatch actions

⚠️ Important: All handlers execute asynchronously (microtask queue) after dispatch returns. This means handlers run after the current synchronous code completes.

// Without data
ActionManager.dispatch(AppAction.zoomIn);

// With data
ActionManager.dispatch(AppAction.userLoggedIn, data: user);

// With additional context
ActionManager.dispatch(
  AppAction.networkError,
  data: errorMessage,
  context: {'retry': true, 'endpoint': '/api/users'},
);

3. Register handlers

✅ Best Practice: Use in StatefulWidget with cleanup

class MyWidget extends StatefulWidget {
  @override
  _MyWidgetState createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  final _subscriptions = <StreamSubscription>[];

  @override
  void initState() {
    super.initState();
    
    // ✅ RECOMMENDED: Use listen() with cleanup
    _subscriptions.add(
      ActionManager.listen<User>(AppAction.userLoggedIn, (event) {
        if (mounted) { // Always check mounted before setState
          final user = event.data;
          setState(() {
            // Update state with user
          });
        }
      }),
    );
  }

  @override
  void dispose() {
    // ✅ CRITICAL: Cancel all subscriptions
    for (final sub in _subscriptions) {
      sub.cancel();
    }
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Container();
  }
}

⚠️ Alternative: Use on() with manual cleanup

class _MyWidgetState extends State<MyWidget> {
  String? _handlerId;

  @override
  void initState() {
    super.initState();
    
    // ⚠️ If using on(), you MUST cleanup manually
    _handlerId = ActionManager.on<User>(AppAction.userLoggedIn, (event) {
      if (mounted) setState(() { /* Update with event.data */ });
    });
  }

  @override
  void dispose() {
    // ✅ CRITICAL: Remove handler
    if (_handlerId != null) {
      ActionManager.off(AppAction.userLoggedIn, _handlerId!);
    }
    super.dispose();
  }
}

🎯 Best: Use ActionManagerWidget (auto cleanup)

// ✅ BEST PRACTICE: Automatic cleanup, no manual subscription management
ActionManagerWidget<User>(
  action: AppAction.userLoggedIn,
  builder: (context, event) {
    final user = event.data;
    return Text('Welcome ${user?.name ?? "Guest"}');
  },
)

Other handler types

// Simple handler (no data)
ActionManager.onVoid(AppAction.zoomIn, (event) {
  print('Zooming in at ${event.timestamp}!');
});

// Handler with data and ActionEvent metadata
ActionManager.on<String>(AppAction.networkError, (event) {
  print('Error at ${event.timestamp}: ${event.data}');
  print('Context: ${event.context}');
});

4. Reactive Widgets (NEW in 4.1.0)

The easiest way to build reactive UIs - widgets that automatically rebuild when actions are dispatched:

// Simple reactive widget
ActionManagerWidget<String>(
  action: AppAction.statusUpdated,
  builder: (context, event) {
    return Text('Status: ${event.data ?? "idle"}');
  },
)

// With placeholder before first event
ActionManagerWidget<User>(
  action: AppAction.userLoggedIn,
  placeholder: CircularProgressIndicator(),
  builder: (context, event) {
    final user = event.data;
    return Text('Welcome ${user?.name ?? "Guest"}!');
  },
)

// Debugging: access full event metadata
ActionManagerWidget<bool>(
  action: AppAction.displayForm,
  builder: (context, event) {
    debugPrint('Action: ${event.action}, Display: ${event.data}');
    debugPrint('Source: ${event.context?["source"]}');
    if (event.data == true) return MyForm();
    return const SizedBox.shrink();
  },
)

// React to multiple actions — event.data is dynamic, cast manually per action
ActionManagerMultiWidget(
  actions: [AppAction.refresh, AppAction.dataUpdated],
  builder: (context, event) {
    return Text('Last update: ${event.timestamp}');
  },
)

Benefits:

  • ✅ Auto-subscription and cleanup (no dispose needed)
  • ✅ Automatic setState() when action is dispatched
  • ✅ Type-safe with generics
  • ✅ Optional placeholder widget

🔄 Advanced Dispatch Methods

dispatchAsync — await all handlers

All on() handlers run in parallel; the call returns when all complete.

final result = await ActionManager.dispatchAsync(AppAction.save, data: record);
print('${result.successCount}/${result.handlerCount} handlers OK');
if (result.hasErrors) {
  for (final err in result.errors) print(err);
}

dispatchMulti — sequential pipeline of events

Executes a list of ActionEvents in order. Action N+1 waits for all handlers of N to finish.

await ActionManager.dispatchMulti([
  ActionEvent.now(AppAction.validate, data: form),
  ActionEvent.now(AppAction.save,     data: form),
  ActionEvent.now(AppAction.notify),
]);

dispatchSticky — BehaviorSubject semantics

Handlers registered after the dispatch immediately receive the last value.

ActionManager.dispatchSticky(AppAction.currentUser, data: user);

// Later — handler fires immediately with the stored value
ActionManager.on<User>(AppAction.currentUser, (event) {
  print(event.data); // receives 'user' even if registered after dispatch
});

ActionManager.clearSticky(AppAction.currentUser); // remove stored value

dispatchPipeline — transform data through a chain of handlers

Each handler receives the result of the previous one. If a handler fails, the next receives the last successful value. The pipeline always runs all handlers.

// Register transform handlers
ActionManager.on<Map>(AppAction.process, (event) {
  return {...event.data!, 'fullName': '${event.data!['first']} ${event.data!['last']}'};
});
ActionManager.on<Map>(AppAction.process, (event) async {
  await db.save(event.data!);
  return event.data; // pass through unchanged
});

// Option A: await the result
final result = await ActionManager.dispatchPipeline<Map>(
  AppAction.process,
  data: {'first': 'John', 'last': 'Doe'},
);
print(result.finalValue);  // {'first': 'John', 'last': 'Doe', 'fullName': 'John Doe'}
print(result.stepCount);   // 2
print(result.hasErrors);   // false

// Option B: fire-and-forget with callback (no await needed)
ActionManager.dispatchPipeline<Map>(
  AppAction.process,
  data: {'first': 'John', 'last': 'Doe'},
  onComplete: (result) => print(result.finalValue),
);

dispatchPipeline is also available directly on TypedAction<T> — see below.

dispatchAsyncCoalesced — deduplicate in-flight dispatches

If the same action already has a dispatchAsync in flight, returns the existing Future instead of launching a parallel dispatch.

final r1 = ActionManager.dispatchAsyncCoalesced(AppAction.loadData);
final r2 = ActionManager.dispatchAsyncCoalesced(AppAction.loadData); // reuses r1

🔒 TypedAction<T> — Compile-time Type Safety

The standard enum-based API works well, but type mismatches between dispatch<T> and on<T> are only caught at runtime (silently swallowed by default). TypedAction<T> solves this by encoding the data type directly in the action constant.

Setup (no changes to existing code)

// Keep your enum as-is
enum AppActions { userLoggedIn, issuesLoaded, refreshUI }

// Add a typed-constants class alongside it
class AppActionsTyped {
  static const userLoggedIn  = TypedAction<UserModel>  (AppActions.userLoggedIn);
  static const issuesLoaded  = TypedAction<List<Issue>>(AppActions.issuesLoaded);
  static const refreshUI     = TypedAction<void>       (AppActions.refreshUI);
}

Dispatch — compiler validates data type

AppActionsTyped.issuesLoaded.dispatch(data: list);   // ✅
AppActionsTyped.issuesLoaded.dispatch(data: true);   // ❌ compile error
AppActionsTyped.refreshUI.dispatch();                // ✅ void, no data needed

// All dispatch variants available — T inferred from TypedAction
await AppActionsTyped.issuesLoaded.dispatchAsync(data: list);
AppActionsTyped.issuesLoaded.dispatchSticky(data: list);

// Pipeline — T inferred, no explicit <T> needed
final result = await AppActionsTyped.issuesLoaded.dispatchPipeline(data: list);

// Fire-and-forget pipeline with callback
AppActionsTyped.issuesLoaded.dispatchPipeline(
  data: list,
  onComplete: (result) => print(result.finalValue),
);

Handler — T inferred, no explicit annotation

// T is inferred as List<Issue> from TypedAction — no <T> needed
AppActionsTyped.issuesLoaded.on((event) {
  final items = event.data!; // List<Issue>, fully typed
});

// Async handler
AppActionsTyped.issuesLoaded.on((event) async {
  await repository.save(event.data!);
});

Introspection

AppActionsTyped.issuesLoaded.hasHandlers;  // bool
AppActionsTyped.issuesLoaded.handlerCount; // int
AppActionsTyped.issuesLoaded.metadata;     // ActionMetadata

Incremental migration

Old code continues to work — migrate file by file at your own pace:

// Old — still works
ActionManager.dispatch<List<Issue>>(AppActions.issuesLoaded, data: list);

// New — type-safe, T inferred
AppActionsTyped.issuesLoaded.dispatch(data: list);

🎯 Use Cases

In Widgets (Manual Subscription)

class MyWidget extends StatefulWidget {
  @override
  State<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  StreamSubscription? _sub;
  
  @override
  void initState() {
    super.initState();
    
    // Listen and rebuild
    _sub = ActionManager.listen(AppAction.dataLoaded, (_) {
      setState(() {});
    });
  }
  
  @override
  void dispose() {
    _sub?.cancel();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () => ActionManager.dispatch(AppAction.zoomIn),
      child: Text('Zoom +'),
    );
  }
}

ActionManagerMixin — Auto-cleanup (NEW in 5.9.0)

Para evitar el boilerplate de cancelar manualmente cada StreamSubscription, añade ActionManagerMixin<W> al State y registra los listeners con bindAction / bindTypedAction. El mixin cancela automáticamente todas las suscripciones en dispose, y desde la 5.9.0 los streams internos por acción se liberan en cuanto su último listener cancela — cero memory leak silencioso.

class _MyScreenState extends State<MyScreen>
    with ActionManagerMixin<MyScreen> {
  User? _user;

  @override
  void initState() {
    super.initState();

    bindAction<User>(AppAction.userLoggedIn, (event) {
      if (event.isInitial) return;
      setState(() => _user = event.data);
    });

    // Versión type-safe con TypedAction<T>
    bindTypedAction(AppTypedActions.dataLoaded, (event) {
      if (event.isInitial) return;
      setState(() {});
    });
  }

  @override
  Widget build(BuildContext context) => Text(_user?.name ?? 'Anonymous');
}

El handler sólo se invoca si mounted == true, así que no hace falta preguntarlo dentro del callback. Si necesitas el StreamSubscription para cancelar antes de dispose (caso raro), bindAction lo devuelve.

In Controllers (GetX example)

class IssueController extends GetxController {
  @override
  void onInit() {
    super.onInit();
    
    ActionManager.on<String>(AppAction.deleteIssue, (event) {
      final id = event.data;
      if (id != null) _deleteIssue(id);
    });
    
    ActionManager.on<List<Issue>>(AppAction.issuesLoaded, (event) {
      this.issues = event.data ?? [];
      update(); // Trigger UI rebuild
    });
  }
  
  void deleteIssue(String id) {
    ActionManager.dispatch(AppAction.deleteIssue, data: id);
  }
}

In Services/Repositories

class IssueRepository {
  IssueRepository() {
    // Listen to UI commands
    ActionManager.on<String>(AppAction.deleteIssue, (event) async {
      final id = event.data;
      if (id != null) {
        await _api.deleteIssue(id);
        await loadIssues(); // Reload data
      }
    });
  }
  
  Future<void> loadIssues() async {
    try {
      final issues = await _api.getIssues();
      
      // Notify entire app
      ActionManager.dispatch(AppAction.issuesLoaded, data: issues);
    } catch (e) {
      ActionManager.dispatch(AppAction.networkError, data: e.toString());
    }
  }
}

🔍 Debugging & Introspection

// Check if action has handlers
if (ActionManager.hasHandlers(AppAction.test)) {
  print('Someone is listening!');
}

// Get handler count
final count = ActionManager.handlerCount(AppAction.test);
print('Handlers: $count');

// Get detailed metadata
final metadata = ActionManager.getMetadata(AppAction.test);
print(metadata); // Shows count, dispatches, last time, etc.

// Get global statistics
final stats = ActionManager.getStats();
print(stats); // Shows all actions, handlers, dispatches

// Print visual summary
ActionManager.printSummary();

Output example:

📊 ═══════════════════════════════════════════
📊 Action Dispatcher Summary
📊 ═══════════════════════════════════════════

🎯 userLoggedIn
   ├─ Handlers: 3
   ├─ Dispatched: 15 times
   └─ Last: 2026-02-09 10:30:45.123

🎯 dataLoaded
   ├─ Handlers: 2
   ├─ Dispatched: 42 times
   └─ Last: 2026-02-09 10:32:10.456

📊 ═══════════════════════════════════════════
📊 Total: 2 actions, 5 handlers, 57 dispatches
📊 ═══════════════════════════════════════════

⚙️ Configuration

// Configure logger
ActionManager.configureLogger(
  enabled: true,           // Enable/disable logging
  showTimestamp: true,     // Include timestamp in logs
);

// Clear all handlers (useful for tests)
ActionManager.clear();

🧪 Testing

void main() {
  setUp(() {
    ActionManager.clear(); // Clean slate for each test
  });

  test('should handle action', () {
    bool called = false;
    
    ActionManager.onVoid(AppAction.test, (event) {
      called = true;
    });
    
    ActionManager.dispatch(AppAction.test);
    
    expect(called, isTrue);
  });
}

🤔 Why ActionManager?

Before (Multiple paradigms)

// UI: GetX reactivity
Obx(() => Text(controller.data.value))

// Cross-controller: EventBus
eventBus.fire(MyEvent(data));

// Service → UI: Callbacks
service.onData = (data) => setState(() {});

// Repository: Streams
repo.dataStream.listen((data) => ...);

After (One system)

// Everywhere:
ActionManager.dispatch(AppAction.dataLoaded, data: data);
ActionManager.on<Data>(AppAction.dataLoaded, (event) => ...);

Benefits:

  • One pattern to learn
  • Consistent throughout the codebase
  • Easy to debug (see all actions in one place)
  • No confusion about which system to use

📄 License

MIT License - see LICENSE file for details.

🤝 Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

📬 Support