event_bus_global
A type-safe, global event bus for Flutter. Emit and listen to events from anywhere in your app: widgets, services, repositories, plain Dart classes.
This is a port of event_bus_riverpod
with the same feature set, exposed through the EventBusGlobal singleton.
What is the best use for this package?
Imagine you have an app that displays store products to the user. On the main page, you have three independent lists showing the most popular, best-selling, and newest products. Tapping a product takes you to the details screen.
What happens if you add the product to the cart from that details screen, or from one of the lists?
What if, on the product details screen, you fetch updated product information that differs from what you currently have?
How would you update that product's information everywhere it appears?
You might devise a solution that ends up creating dependencies between your controllers, services, and widgets. However, with this library, you simply need to have each widget or service listen for events — such as an update to product X, or product Y being added to or removed from the cart — receiving the data needed to manually update your list or details screen. You emit these events whenever changes occur, and — magically — the product appears updated everywhere.
Easy, simple, and fast.
Features
- Type-safe events – each event carries a generic type
T, preventing type mismatches - Global API – use the bus from anywhere with
EventBusGlobal.event()/EventBusGlobal.subEvent(); backed by a singleton core - Manual lifecycle – subscribe/unsubscribe manually with
ListenerDisposable - Async support –
listenManuallyAsync()/emitAsync()for listeners that need to await async work (API calls, DB operations) - Multiple listeners – many listeners can subscribe to the same event
- Error isolation – a failing callback never breaks other listeners
- Error handling – catch errors per-listener with
onErrorcallback (sync and async) - Stream API – consume events as a
Stream<T>(orStream<(T, BusMetadata)>withstreamWithMeta()) for composition andStreamBuilder; supports broadcast mode for multiple subscribers viastream(broadcast: true) - Robust key routing – events are internally routed with
Typehashing instead of string interpolation, ensuring platform-independent key generation - Sticky events – cache the last emitted value and deliver it to new subscribers with
sticky: true; read it anytime vialastValuewithout subscribing - Middleware pipeline – intercept, transform, or cancel events before they reach listeners with
applyMiddleware() - Execution priority – control listener order with the
priorityparameter (higher values run first); defaults to0 - BusMetadata – every emission carries an auto-generated
timestamp; optionally attach asourceidentifier and arbitrary extra data; access via*WithMetalistener methods - Listener filter – filter which emissions reach a listener with the
whereparameter, using the value and/or its metadata - SubEvents – create filtered views of events with their own sticky cache and listener list using a mandatory
wherepredicate; accessed viaEventBusGlobal.subEvent() - Full reset – wipe all listeners, sticky caches, middlewares, and subEvents at once with
EventBusGlobal.clearAll() - EventBusBuilder widget – a widget that rebuilds whenever an event is emitted; accepts both
EventBusIdentifierandSubEventIdentifierpolymorphically viaEventBusIdentifierBase
Table of Contents
- event_bus_global
- What is the best use for this package?
- Features
- Table of Contents
- Installing
- Usage
- 1. Define an event identifier
- 2. Emit an event from anywhere
- 3. Listen to an event from anywhere
- 4. Listen inside a widget
- 5. Check if an event has active listeners
- 6. Null-safe events
- 7. Error handling with
onError - 8. Stream API
- 9. Clear all listeners of an event
- 10. Async listeners
- 11. Sticky events (last value cache)
- 12. Middleware pipeline
- 13. Execution priority
- 14. BusMetadata (emission metadata)
- 15. Listener filter with
where - 16. SubEvents
- 17. One-shot listeners (
listenOnceManually) - 18. Event history (last N values)
- 19. Logger interceptor
- 20. EventBusBuilder widget
- 21. Await the next emission with
waitFor() - 22. Reset the bus
Installing
Add the dependency from pub.dev:
dependencies:
event_bus_global: ^1.0.1
Usage
1. Define an event identifier
Create a typed identifier for each event. The generic type T is the payload type.
import 'package:event_bus_global/event_bus_global.dart';
class EventBusConstants {
static final onUserNameChanged = EventBusIdentifier<String>('onUserNameChanged');
static final onUserAgeChanged = EventBusIdentifier<int>('onUserAgeChanged');
static final onLoginStatusChanged = EventBusIdentifier<bool>('onLoginStatusChanged');
}
2. Emit an event from anywhere
Call EventBusGlobal.event(...).emit(value) — all active listeners are notified
synchronously.
// From a widget
class UserInputWidget extends StatefulWidget {
const UserInputWidget({super.key});
@override
State<UserInputWidget> createState() => _UserInputWidgetState();
}
class _UserInputWidgetState extends State<UserInputWidget> {
@override
Widget build(BuildContext context) {
return TextField(
onSubmitted: (value) {
EventBusGlobal.event(EventBusConstants.onUserNameChanged).emit(value);
},
);
}
}
// From a plain Dart service — no widget, no context needed
class AnalyticsService {
void trackScreen(String screenName) {
EventBusGlobal.event(onScreenView).emit(screenName);
}
}
3. Listen to an event from anywhere
The listen methods return a ListenerDisposable. Call dispose() when you no
longer want to receive events.
class CartRepository {
ListenerDisposable? _disposable;
void startListening() {
_disposable = EventBusGlobal.event(onAddToCart).listenManually((item) {
_saveToLocalDb(item);
});
}
void stopListening() {
_disposable?.dispose();
}
}
4. Listen inside a widget
In a StatefulWidget, subscribe in initState and dispose in dispose:
class _MyWidgetState extends State<MyWidget> {
ListenerDisposable? _disposable;
@override
void initState() {
super.initState();
_disposable = EventBusGlobal
.event(EventBusConstants.onUserAgeChanged)
.listenManually((age) {
print('Age changed to $age');
});
}
@override
void dispose() {
_disposable?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () =>
EventBusGlobal.event(EventBusConstants.onUserAgeChanged).emit(30),
child: const Text('Set age to 30'),
);
}
}
If the whole widget just needs to render the current value, prefer the
EventBusBuilder widget — it manages the
subscription lifecycle for you.
5. Check if an event has active listeners
if (EventBusGlobal.event(EventBusConstants.onLoginStatusChanged).hasClients) {
EventBusGlobal.event(EventBusConstants.onLoginStatusChanged).emit(true);
}
6. Null-safe events
Nullable types are fully supported.
final onNullable = EventBusIdentifier<String?>('onNullable');
EventBusGlobal.event(onNullable).listenManually((value) {
print(value); // can be null or String
});
EventBusGlobal.event(onNullable).emit(null);
7. Error handling with onError
When a listener throws, other listeners are not affected. You can catch
errors per-listener with onError:
EventBusGlobal.event(EventBusConstants.onUserAgeChanged).listenManually((age) {
if (age < 0) throw Exception('Invalid age: $age');
}, onError: (error, stackTrace) {
log('Listener failed: $error', stackTrace: stackTrace);
});
If no onError is provided, errors are logged to the console in debug mode via
log():
EventBusGlobal.event(EventBusConstants.onUserAgeChanged).listenManually((age) {
// If this throws, a warning is printed in debug mode
});
The onError parameter is also available on all async and one-shot variants:
final disposable = EventBusGlobal
.event(EventBusConstants.onUserAgeChanged)
.listenManuallyAsync((age) async {
throw Exception('Oops');
}, onError: (error, stackTrace) {
print('Caught: $error');
});
8. Stream API
Each event can be consumed as a Stream<T>, enabling stream composition and
StreamBuilder widgets.
// StreamBuilder
StreamBuilder<int>(
stream: EventBusGlobal.event(EventBusConstants.onUserAgeChanged).stream(),
builder: (context, snapshot) {
if (!snapshot.hasData) return const Text('No data');
return Text('Age: ${snapshot.data}');
},
);
// Stream composition
EventBusGlobal.event(EventBusConstants.onUserAgeChanged).stream()
.where((age) => age >= 18)
.map((age) => 'Adult aged $age')
.listen((msg) => print(msg));
// Catch errors using standard stream error handling
EventBusGlobal.event(EventBusConstants.onUserAgeChanged).stream()
.listen(
(age) => print('Age: $age'),
onError: (error, stackTrace) {
log('Stream error: $error', stackTrace: stackTrace);
},
);
// Or use handleError for composition
EventBusGlobal.event(EventBusConstants.onUserAgeChanged).stream()
.handleError((error) => log('Error: $error'))
.listen((age) => print('Age: $age'));
Broadcast mode
By default, stream() returns a single-subscription stream — calling
.listen() more than once throws a BadState error. The broadcast parameter
(false by default) changes the underlying StreamController to broadcast
mode, allowing multiple subscribers on the same stream.
final stream = EventBusGlobal.event(onCounter).stream(broadcast: true);
// Multiple subscribers — no error
stream.listen((v) => print('Listener 1: $v'));
stream.listen((v) => print('Listener 2: $v'));
Multiple subscribers share a single internal _ListenerEntry — only one entry
is registered in the bus regardless of how many .listen() calls are made:
// Single-subscription — only one .listen() allowed
final single = EventBusGlobal.event(onCounter).stream();
single.listen(print);
single.listen(print); // 💥 Bad state
// Broadcast — multiple .listen() allowed
final multi = EventBusGlobal.event(onCounter).stream(broadcast: true);
multi.listen(print); // subscriber 1
multi.listen(print); // subscriber 2 — both receive events
The broadcast parameter is available on all stream methods:
| Method | broadcast param |
|---|---|
stream() |
✅ |
streamWithMeta() |
✅ |
Stream methods also support sticky, priority, and where — see their
respective sections for details.
Recipe: event as a StreamBuilder widget
Wrap the stream directly in a StreamBuilder — the widget rebuilds on every
emission:
class CounterBadge extends StatelessWidget {
const CounterBadge({super.key});
@override
Widget build(BuildContext context) {
return StreamBuilder<int>(
stream: EventBusGlobal.event(onCounter).stream(broadcast: true),
builder: (context, snapshot) {
return Badge(
label: Text('${snapshot.data ?? 0}'),
);
},
);
}
}
Pass sticky: true on the stream and use lastValue as initialData to start
with the cached value before the first emission arrives:
StreamBuilder<int>(
stream: EventBusGlobal.event(onCounter).stream(sticky: true, broadcast: true),
initialData: EventBusGlobal.event(onCounter).lastValue,
builder: (context, snapshot) => Text('${snapshot.data ?? 0}'),
);
9. Clear all listeners of an event
Use clearListeners() to remove all listeners registered for a specific event
without affecting other events or the bus itself.
EventBusGlobal.event(EventBusConstants.onUserAgeChanged)
.listenManually((age) => print('Age: $age'));
EventBusGlobal.event(EventBusConstants.onUserAgeChanged)
.listenManually((age) => print('Age again: $age'));
// Remove all listeners for onUserAgeChanged
EventBusGlobal.event(EventBusConstants.onUserAgeChanged).clearListeners();
// Other events remain unaffected
EventBusGlobal.event(EventBusConstants.onUserNameChanged)
.listenManually((name) => print('Name: $name'));
After calling clearListeners(), the event no longer has active listeners:
print(EventBusGlobal.event(EventBusConstants.onUserAgeChanged).hasClients); // false
Clear all events
To wipe the entire bus — all listeners, subEvent listeners, middlewares,
sticky caches, and subEvent registrations — use EventBusGlobal.clearAll(). This
is useful during user logout or full app reset.
class AuthService {
Future<void> logout() async {
await _api.logout();
EventBusGlobal.clearAll();
// Now every event and subEvent is clean: no listeners, no cached
// values, no middlewares.
}
}
Internally clearAll() clears listeners, sticky caches, middlewares, subEvent
listeners, subEvent sticky caches, and subEvent registrations across all events.
10. Async listeners
When a listener needs to perform asynchronous work (e.g., API calls, database
operations), use listenManuallyAsync() instead of listenManually(). The event
bus tracks async listeners separately and exposes emitAsync() that awaits all
async listeners before resolving.
Scenario: After a user logs in, multiple services need to fetch data (cart, preferences, notifications) before navigating to the home screen.
// Define the event
final onUserLogin = EventBusIdentifier<User>('onUserLogin');
// Login service — emit and wait
class LoginService {
Future<void> login(String email, String password) async {
final user = await _api.login(email, password);
await EventBusGlobal.event(onUserLogin).emitAsync(user); // ✅ waits for all listeners
navigateToHome(); // safe — data is ready
}
}
// Cart service — restore cart asynchronously
class CartService {
CartService() {
EventBusGlobal.event(onUserLogin).listenManuallyAsync((user) async {
final cart = await _api.restoreCart(user.id);
_cartState.setCart(cart);
});
}
}
// Preferences service — load preferences asynchronously
class PrefsService {
PrefsService() {
EventBusGlobal.event(onUserLogin).listenManuallyAsync((user) async {
final prefs = await _api.fetchPreferences(user.id);
_prefsState.setPrefs(prefs);
});
}
}
Async API overview:
| Context | Method | Auto-dispose | Returns |
|---|---|---|---|
| Global | listenManuallyAsync(cb) |
❌ (manual) | ListenerDisposable |
| Global | emitAsync(value) |
— | Future<void> |
Error handling: Same onError callback works with async listeners:
EventBusGlobal.event(onUserLogin).listenManuallyAsync((user) async {
throw Exception('Failed to process user');
}, onError: (error, stackTrace) {
log('Async listener error: $error', stackTrace: stackTrace);
});
Mixing sync and async listeners: emitAsync() runs sync listeners first,
then awaits all async listeners in parallel. Sync listeners are not awaited.
EventBusGlobal.event(onUserLogin).listenManually((user) {
log('User logged in: ${user.name}'); // runs synchronously
});
EventBusGlobal.event(onUserLogin).listenManuallyAsync((user) async {
await _fetchData(); // awaited by emitAsync
});
await EventBusGlobal.event(onUserLogin).emitAsync(user); // awaits only async listeners
11. Sticky events (last value cache)
When you emit an event, the last value is cached. New subscribers using
sticky: true receive the cached value immediately upon subscription,
without waiting for the next emit().
This is useful when a screen or service is created after the event already fired — for example, a user logs in and later a new screen/feature loads that needs the current user.
Scenario: After login, the user object is emitted. A widget loaded later receives the user immediately.
// Login screen — emit user on login
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () async {
final user = await authenticate();
EventBusGlobal.event(onUserLogin).emit(user); // cachea el user
},
child: const Text('Login'),
);
}
}
// Profile widget — created AFTER login (lazy, new route, etc.)
class ProfileWidget extends StatefulWidget {
const ProfileWidget({super.key});
@override
State<ProfileWidget> createState() => _ProfileWidgetState();
}
class _ProfileWidgetState extends State<ProfileWidget> {
User? _currentUser;
ListenerDisposable? _disposable;
@override
void initState() {
super.initState();
// sticky: true → receives the last user immediately if one exists
_disposable = EventBusGlobal.event(onUserLogin).listenManually((user) {
setState(() => _currentUser = user);
}, sticky: true);
}
@override
void dispose() {
_disposable?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Text('User: ${_currentUser?.name ?? 'not logged in'}');
}
}
Available on all listen and stream methods:
| Method | sticky param |
|---|---|
listenManually(cb, sticky: true) |
✅ |
listenManuallyAsync(cb, sticky: true) |
✅ |
listenManuallyWithMeta(cb, sticky: true) |
✅ |
listenManuallyAsyncWithMeta(cb, sticky: true) |
✅ |
stream(sticky: true) |
✅ |
streamWithMeta(sticky: true) |
✅ |
Nullable values: Null is cached if the event type allows it
(EventBusIdentifier<String?>).
final onNullable = EventBusIdentifier<String?>('onNullable');
EventBusGlobal.event(onNullable).emit(null);
EventBusGlobal.event(onNullable).listenManually((v) {
print(v); // null — received immediately from sticky cache
}, sticky: true);
Clear the sticky cache:
EventBusGlobal.event(onUserLogin).clearSticky(); // next sticky subscriber won't receive anything
Last value (unsubscribed access)
Both EventBusActionForGlobal<T> and SubEventActionForGlobal<T> expose
T? get lastValue to read the last emitted value without subscribing — useful
to initialize a form field or show a snapshot.
// Read the last emitted value of an event
final lastUser = EventBusGlobal.event(onUserLogin).lastValue;
if (lastUser != null) {
print('Last logged in user: ${lastUser.name}');
}
// After emitting, lastValue reflects the latest value
EventBusGlobal.event(onSecureInt).emit(42);
print(EventBusGlobal.event(onSecureInt).lastValue); // 42
EventBusGlobal.event(onSecureInt).emit(100);
print(EventBusGlobal.event(onSecureInt).lastValue); // 100
// Before any emission, lastValue is null
print(EventBusGlobal.event(onSecureInt).lastValue); // null
// After clearSticky(), lastValue returns null
EventBusGlobal.event(onSecureInt).emit(42);
EventBusGlobal.event(onSecureInt).clearSticky();
print(EventBusGlobal.event(onSecureInt).lastValue); // null
// Works on SubEvents too
final evenAction = EventBusGlobal.subEvent(evenSecureInt);
print(evenAction.lastValue); // null — nothing emitted yet
EventBusGlobal.event(onSecureInt).emit(2); // 2 is even, passes the where
print(evenAction.lastValue); // 2
EventBusGlobal.event(onSecureInt).emit(3); // 3 is odd, does not pass
print(evenAction.lastValue); // 2 — still the last matching value
// Use in a widget
class UserStatusBadge extends StatelessWidget {
const UserStatusBadge({super.key});
@override
Widget build(BuildContext context) {
final online = EventBusGlobal.event(onUserOnline).lastValue;
return Badge(
color: online == true ? Colors.green : Colors.grey,
child: const Icon(Icons.person),
);
}
}
| Type | Getter |
|---|---|
EventBusActionForGlobal<T> |
T? get lastValue |
SubEventActionForGlobal<T> |
T? get lastValue |
12. Middleware pipeline
Middleware intercepts events before they reach listeners. Each middleware
can log, transform, or cancel the event by deciding whether to call next().
Scenario: E-commerce app with logging, validation, and currency conversion on cart events.
final onAddToCart = EventBusIdentifier<CartItem>('onAddToCart');
// Middleware 1 — logging (does not modify the value)
EventBusGlobal.event(onAddToCart).applyMiddleware((item, next) {
log('[Cart] Adding: ${item.productId} x${item.quantity}');
next(item);
});
// Middleware 2 — validation (cancels if user is banned)
EventBusGlobal.event(onAddToCart).applyMiddleware((item, next) {
if (userIsBanned) {
log('[Cart] User banned, blocked');
return; // does not call next → event cancelled
}
next(item);
});
// Middleware 3 — transformation (converts price to local currency)
EventBusGlobal.event(onAddToCart).applyMiddleware((item, next) {
final converted = item.copyWith(price: item.price * exchangeRate);
next(converted);
});
// Listeners receive the already processed value
EventBusGlobal.event(onAddToCart).listenManually((item) {
_cartState.add(item);
});
Removing a middleware:
final disposable = EventBusGlobal.event(onAddToCart).applyMiddleware((item, next) {
log('Temporary logging');
next(item);
});
// Stop logging
disposable.dispose();
// Or remove all middlewares from the event.
EventBusGlobal.event(onAddToCart).clearMiddlewares();
Middleware API reference:
| Method | Description |
|---|---|
applyMiddleware(middleware) |
Registers a middleware, returns ListenerDisposable |
clearMiddlewares() |
Removes all middlewares from the event. |
13. Execution priority
By default listeners run in FIFO order (default priority is 0). Use the
priority parameter to control execution order — higher values run first.
EventBusGlobal.event(onCounter).listenManually((v) {
// runs first
}, priority: 10);
EventBusGlobal.event(onCounter).listenManually((v) {
// runs after priority 10 — default is 0
}, priority: 0);
Negative values are also supported — listeners with lower priority run last:
EventBusGlobal.event(onCounter).listenManually((v) {
// runs after all default-priority listeners
}, priority: -5);
Available on all listen and stream methods:
| Method | priority param |
|---|---|
listenManually(cb, priority: n) |
✅ |
listenManuallyAsync(cb, priority: n) |
✅ |
listenManuallyWithMeta(cb, priority: n) |
✅ |
listenManuallyAsyncWithMeta(cb, priority: n) |
✅ |
stream(priority: n) |
✅ |
streamWithMeta(priority: n) |
✅ |
Listeners with the same priority execute in FIFO order:
EventBusGlobal.event(onCounter).listenManually((v) {
print('first');
}, priority: 5);
EventBusGlobal.event(onCounter).listenManually((v) {
print('second'); // same priority → FIFO
}, priority: 5);
14. BusMetadata (emission metadata)
Every call to emit() / emitAsync() automatically generates a BusMetadata
object with a precise timestamp. The emitter can optionally pass source and
extraData directly to carry extra context all the way to the listeners.
| Type | Purpose | Who creates it |
|---|---|---|
BusMetadata |
Received by *WithMeta listeners; auto-generated by the bus |
The bus |
Emitting with metadata
// Without metadata — timestamp is still generated
EventBusGlobal.event(onUserLogin).emit(user);
// With source
EventBusGlobal.event(onUserLogin).emit(user, source: 'login_screen');
// With source + arbitrary extra data
EventBusGlobal.event(onUserLogin).emit(
user,
source: 'login_screen',
extraData: {'loginMethod': 'google', 'sessionId': 'abc123'},
);
// Also works with emitAsync
await EventBusGlobal.event(onUserLogin).emitAsync(user, source: 'login_screen');
Listening with metadata
Use the *WithMeta variants — the callback receives BusMetadata as the second
argument:
// Manual lifecycle
final disposable = EventBusGlobal.event(onUserLogin).listenManuallyWithMeta((user, meta) {
print('User logged in at ${meta.timestamp}');
print('From: ${meta.source}');
print('Extra: ${meta.extraData}');
});
disposable.dispose();
// Manual async
final d2 = EventBusGlobal.event(onUserLogin).listenManuallyAsyncWithMeta((user, meta) async {
await analytics.track('login', {
'source': meta.source,
'timestamp': meta.timestamp.toIso8601String(),
});
});
Practical scenario: audit trail
Emit cart actions with context about who performed them:
EventBusGlobal.event(onAddToCart).emit(item,
source: 'product_detail',
extraData: {
'userId': currentUser.id,
'sessionId': sessionId,
'device': 'mobile',
},
);
The listener logs the full audit trail:
EventBusGlobal.event(onAddToCart).listenManuallyWithMeta((item, meta) {
auditLog.add(AuditEntry(
productId: item.productId,
timestamp: meta.timestamp,
source: meta.source,
metadata: meta.extraData,
));
});
Sticky + metadata
The sticky cache stores the metadata alongside the value. A *WithMeta
subscriber with sticky: true receives the original metadata from the moment
the value was emitted:
EventBusGlobal.event(onUserLogin).emit(user, source: 'onboarding');
// Later, a new subscriber joins — receives the cached metadata too
EventBusGlobal.event(onUserLogin).listenManuallyWithMeta((user, meta) {
print(meta.source); // "onboarding" — original metadata preserved
}, sticky: true);
Metadata API reference
| Method | source and extraData on emit |
|---|---|
emit(value) |
✅ optional source: / extraData: |
emitAsync(value) |
✅ optional source: / extraData: |
| Method | Receives BusMetadata |
|---|---|
listenManually(cb) |
❌ |
listenManuallyWithMeta(cb) |
✅ |
listenManuallyAsync(cb) |
❌ |
listenManuallyAsyncWithMeta(cb) |
✅ |
stream() |
❌ |
streamWithMeta() |
✅ (via (T, BusMetadata) record) |
15. Listener filter with where
Every listen method accepts an optional where parameter — a predicate
bool Function(T value, BusMetadata metadata) that decides whether the listener
should fire. If where returns false, the listener is skipped for that
emission. The listener is still registered and will fire on future matching
emissions.
Filtering by value — your detail page update scenario:
class UserDetailController {
final String userId;
UserDetailController(this.userId) {
EventBusGlobal.event(onUpdateUser).listenManually((updated) {
_state.value = updated;
}, where: (u, _) => u.id == userId);
}
}
Each detail page only reacts to updates for its own user, even though the event is broadcast to all.
Filtering by metadata source:
EventBusGlobal.event(onAddToCart).listenManually((item) {
// only process events from trusted sources
}, where: (item, meta) => meta.source == 'payment_gateway');
Filtering with complex logic (value + metadata):
EventBusGlobal.event(onDataSync).listenManuallyWithMeta((data, meta) {
process(data);
}, where: (data, meta) {
return data.version > currentVersion &&
meta.source != 'legacy_system';
});
Where + sticky:
The predicate also applies to cached sticky values — a non-matching cached value is not delivered:
// Emit a value, then subscribe with where + sticky
EventBusGlobal.event(onCounter).emit(-1);
EventBusGlobal.event(onCounter).listenManually((v) {
// never called — where filters out -1
}, sticky: true, where: (v, _) => v > 0);
Available on all listen and stream methods:
| Method | where param |
|---|---|
listenManually(cb, where: ...) |
✅ |
listenManuallyAsync(cb, where: ...) |
✅ |
listenManuallyWithMeta(cb, where: ...) |
✅ |
listenManuallyAsyncWithMeta(cb, where: ...) |
✅ |
stream(where: ...) |
✅ |
streamWithMeta(where: ...) |
✅ |
16. SubEvents
A SubEvent is a filtered view of a parent event. It has its own listener list
and sticky cache, independent from the parent. Unlike the where parameter on
regular listeners — which is per-listener and disposable — a SubEvent's where
predicate is part of its identity and shared across all its listeners.
SubEvents are listen-only: they fire automatically when the parent event
emits and the value matches the SubEvent's where. You never emit() to a
SubEvent directly.
Scenario: A user‑management app has multiple detail pages open at the same time, each for a different user. When any page updates a user, only that user's detail page should react.
// Define the parent event
final onUpdateUser = EventBusIdentifier<User>('onUpdateUser');
// Factory: create a SubEvent per userId
SubEventIdentifier<User> onUpdateUserOf(String userId) =>
SubEventIdentifier(
userId,
parentEvent: onUpdateUser,
where: (user, _) => user.userId == userId,
);
Each detail page creates its own SubEvent identity:
class UserDetailPage extends StatefulWidget {
final String userId;
const UserDetailPage({super.key, required this.userId});
@override
State<UserDetailPage> createState() => _UserDetailPageState();
}
class _UserDetailPageState extends State<UserDetailPage> {
ListenerDisposable? _disposable;
@override
void initState() {
super.initState();
// Only reacts to updates for widget.userId
_disposable = EventBusGlobal.subEvent(onUpdateUserOf(widget.userId))
.listenManually((user) {
_controller.update(user);
});
}
@override
void dispose() {
_disposable?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return UserDetailContent(userId: widget.userId);
}
}
Sticky cache: SubEvents have their own independent sticky cache. When a new
subscriber joins with sticky: true, the last matching value is delivered
immediately — even if the parent event has never been emitted after the SubEvent
was created.
// Somewhere else: a user is updated
EventBusGlobal.event(onUpdateUser).emit(User('user-42', name: 'Alice'));
// Later, a new subscriber joins — receives user-42 immediately
class User42Profile extends StatefulWidget {
const User42Profile({super.key});
@override
State<User42Profile> createState() => _User42ProfileState();
}
class _User42ProfileState extends State<User42Profile> {
ListenerDisposable? _disposable;
@override
void initState() {
super.initState();
_disposable = EventBusGlobal.subEvent(onUpdateUserOf('user-42'))
.listenManually((user) {
print(user.name); // 'Alice' — from SubEvent's sticky cache
}, sticky: true);
}
@override
void dispose() {
_disposable?.dispose();
super.dispose();
}
}
SubEvent API — available on EventBusGlobal.subEvent():
| Method | Available |
|---|---|
listenManually(cb) / listenManuallyAsync(cb) |
✅ |
listenManuallyWithMeta(cb) / listenManuallyAsyncWithMeta(cb) |
✅ |
stream() / streamWithMeta() |
✅ |
listenOnceManually(cb) / listenOnceManuallyWithMeta(cb) |
✅ |
waitFor() / waitForWithMeta() |
✅ |
hasClients |
✅ |
lastValue |
✅ |
history / clearHistory() |
✅ |
clearListeners() / clearSticky() |
✅ |
emit() / emitAsync() |
❌ |
applyMiddleware() |
❌ (middleware lives on the parent) |
SubEvent reference table:
| Feature | Behaviour |
|---|---|
| Listen type | Listen‑only; triggered by parent emission |
where |
Mandatory — part of the SubEvent identity |
| Sticky cache | Independent of the parent event; backfills from parent on first subscription |
where in listener |
Optional — further narrows per‑listener, applied after the SubEvent where |
| Middleware | SubEvents inherit the parent's middleware pipeline |
17. One-shot listeners (listenOnceManually)
Use listenOnceManually() to react to the next emission only — the listener
removes itself automatically after firing. It returns a ListenerDisposable for
manual cleanup (e.g., if the event never fires).
class _MyWidgetState extends State<MyWidget> {
ListenerDisposable? _disposable;
@override
void initState() {
super.initState();
_disposable = EventBusGlobal.event(EventBusConstants.onUserLogin)
.listenOnceManually((user) {
navigateToHome(); // fires once, then auto-removes
});
}
@override
void dispose() {
_disposable?.dispose(); // optional: clean up if event never fires
super.dispose();
}
}
One-shot listeners support all the same options as regular listeners — sticky,
where, priority, onError, and the *WithMeta variants:
// One-shot with sticky — fires with the cached value, removes itself
EventBusGlobal.event(onCounter).listenOnceManually((v) {
print('First emission was $v');
}, sticky: true);
// One-shot with metadata
EventBusGlobal.event(onUserLogin).listenOnceManuallyWithMeta((user, meta) {
print('Logged in at ${meta.timestamp}');
});
// One-shot with where filter
EventBusGlobal.event(onData).listenOnceManually((data) {
process(data);
}, where: (data, _) => data.isReady);
// One-shot with error handling
final d = EventBusGlobal.event(onApiCall).listenOnceManually((result) {
handleResult(result);
}, onError: (e, st) => log('API failed: $e'));
// One-shot for subEvents
EventBusGlobal.subEvent(evenSecureInt).listenOnceManually((v) {
print('First even number: $v');
}, where: (v, _) => v > 10);
| Method | Context | Auto-dispose | Returns |
|---|---|---|---|
listenOnceManually(cb) |
Global | ❌ | ListenerDisposable |
listenOnceManuallyWithMeta(cb) |
Global | ❌ | ListenerDisposable |
All methods are also available on subEvents via
EventBusGlobal.subEvent(...).listenOnceManually(...) /
listenOnceManuallyWithMeta(...).
18. Event history (last N values)
Each event can optionally keep a circular buffer of the last N emitted values. Configure the buffer size at identifier creation time:
final onCounter = EventBusIdentifier<int>('onCounter', historySize: 20);
The history stores post-middleware values with their BusMetadata. Read it
at any time without subscribing:
final recent = EventBusGlobal.event(onCounter).history;
// => List<ValueWithMeta<int>> — [ValueWithMeta(1, meta), ValueWithMeta(2, meta), ...]
print('Last value: ${recent.last.value}');
print('At: ${recent.last.metadata.timestamp}');
The buffer is circular — older values are dropped when the size is exceeded:
final onCounter = EventBusIdentifier<int>('onCounter', historySize: 3);
EventBusGlobal.event(onCounter).emit(1);
EventBusGlobal.event(onCounter).emit(2);
EventBusGlobal.event(onCounter).emit(3);
EventBusGlobal.event(onCounter).emit(4);
EventBusGlobal.event(onCounter).emit(5);
print(EventBusGlobal.event(onCounter).history.map((e) => e.value).toList());
// => [3, 4, 5] (1 and 2 were dropped)
Real-world example: track whether a value actually changed. Set
historySize: 2 and compare the previous and current values inside a listener:
final onBatteryLevel = EventBusIdentifier<double>('onBatteryLevel', historySize: 2);
class BatteryMonitor {
BatteryMonitor() {
EventBusGlobal.event(onBatteryLevel).listenManually((level) {
final h = EventBusGlobal.event(onBatteryLevel).history;
if (h.length >= 2) {
final prev = h[h.length - 2].value;
if ((level - prev).abs() > 0.05) {
log('Battery changed significantly: $prev → $level');
// Update UI, trigger alerts, etc.
}
} else {
log('First battery reading: $level');
}
});
}
}
Clear the history without affecting listeners or sticky cache:
EventBusGlobal.event(onCounter).clearHistory();
print(EventBusGlobal.event(onCounter).history); // []
SubEvents have their own independent history, populated only with values that
pass their where:
final onCounter = EventBusIdentifier<int>('onCounter', historySize: 10);
final evens = SubEventIdentifier<int>(
'evens',
parentEvent: onCounter,
where: (v, _) => v.isEven,
historySize: 5,
);
// ... subscribe to evens, then emit
for (int i = 1; i <= 10; i++) {
EventBusGlobal.event(onCounter).emit(i);
}
print(EventBusGlobal.event(onCounter).history.length); // 10
print(EventBusGlobal.subEvent(evens).history.length); // 5 (only evens)
Rules:
| Setting | Default | Behaviour |
|---|---|---|
historySize |
0 |
No history kept. Overhead is zero. |
assert |
historySize >= 0 |
Negative values throw at construction time. |
| Storage | Post-middleware | Coherent with sticky cache and listener delivery. |
clearHistory() |
— | Empties the buffer; next emission starts fresh. |
clearSticky() |
— | Does not affect history. |
clearAll() |
— | Also clears all history. |
clearListeners() |
— | Does not affect history. |
19. Logger interceptor
Register one or more global callbacks that fire for every event emission, before middlewares are applied. Useful for logging, analytics, or debugging.
final disposable = EventBusGlobal.logEvents((entry) {
log('[${entry.eventName}] ${entry.value}');
});
// later: disposable.dispose();
Multiple loggers: each call to logEvents() adds a new callback to the
stack. Multiple services, widgets, and screens can all log independently without
overwriting each other. Each callback is cleaned up individually when its
ListenerDisposable is disposed.
// Both loggers coexist — neither overwrites the other
EventBusGlobal.logEvents((entry) {
analytics.track(entry.eventName, {'value': entry.value});
});
EventBusGlobal.logEvents((entry) {
log('[${entry.eventName}] ${entry.value}');
});
What gets logged
Every call to emit() / emitAsync() fires each registered callback with a
LogEntry<Object?> containing:
eventName— the name of theEventBusIdentifiervalue— the raw value before middlewaresmetadata— theBusMetadata(timestamp, source, extraData)
The callback runs before middleware, so you always see the original value even if middleware transforms or cancels the event.
Error isolation
If a callback throws, the error is silently caught — it never crashes the bus, affects listeners, or stops other log callbacks from firing.
When used with SubEvents
The logger fires for the parent event, not for each subEvent. SubEvents are derived views and do not emit independently.
20. EventBusBuilder widget
EventBusBuilder is a widget that rebuilds whenever an event is emitted. It
accepts both EventBusIdentifier and SubEventIdentifier polymorphically
through a common EventBusIdentifierBase type — no need to worry about which
type you pass.
EventBusBuilder<int>(
event: onCounter,
builder: (context, value) => Text('${value ?? 0}'),
)
The widget manages its own subscription lifecycle automatically — it subscribes
in initState, unsubscribes in dispose, and re-subscribes if the event
identifier or filter parameters change.
Parameters:
| Parameter | Type | Description |
|---|---|---|
event |
EventBusIdentifierBase<T> |
The event or subEvent to listen to |
builder |
Widget Function(BuildContext, T?) |
Called on each emission with the new value (T? — null before any emission) |
sticky |
bool |
If true, deliver the last cached value immediately (has priority over initialData) |
initialData |
T? |
Initial value shown before the first emission (only used when there's no sticky cached value) |
where |
ListenerWhere<T>? |
Optional predicate to filter which emissions trigger a rebuild |
priority |
int |
Listener execution priority (default 0, higher runs first) |
Sticky + initialData:
When sticky: true and there's a cached value, the sticky value takes
precedence. initialData is only used when there's no sticky cache:
// Sticky value (42) overrides initialData (0)
EventBusGlobal.event(onCounter).emit(42);
EventBusBuilder<int>(
event: onCounter,
builder: (ctx, value) => Text('${value ?? 0}'),
sticky: true,
initialData: 0,
);
// Shows "42", not "0"
With SubEvent:
EventBusBuilder<int>(
event: evenSecureInt, // SubEventIdentifier — only fires for even values
builder: (ctx, value) => Text('Even: $value'),
);
With where filter:
EventBusBuilder<int>(
event: onCounter,
builder: (ctx, value) => Text('${value ?? 0}'),
where: (v, _) => v > 0, // only rebuilds for positive values
);
21. Await the next emission with waitFor()
waitFor() returns a Future<T> that completes with the value of the next
matching emission. Think of it as a one-shot listener wrapped in a Future —
useful for inline async coordination.
Scenario: navigation after login
A login screen emits a User event. Multiple services start fetching data
asynchronously (cart, preferences, notifications). The navigation code needs to
wait for all services to finish before pushing the home screen:
class LoginService {
Future<void> login(String email, String password) async {
final user = await _api.login(email, password);
// emitAsync waits for all async listeners
await EventBusGlobal.event(onUserLogin).emitAsync(user);
// Now wait for a specific event that signals data is ready
await EventBusGlobal.event(onDataReady).waitFor(
timeout: Duration(seconds: 10),
);
navigateToHome(); // safe — data is loaded
}
}
Scenario: handling timeout gracefully
When waitFor times out, it throws a TimeoutException. Catch it to handle the
failure case — retry, show feedback, or fall back:
try {
await EventBusGlobal.event(onPaymentConfirmation).waitFor(
timeout: Duration(seconds: 15),
where: (status, _) => status == PaymentStatus.confirmed,
);
showSuccessToast('Payment confirmed!');
} on TimeoutException {
EventBusGlobal.event(onShowSnackbar).emit('Payment is taking longer than expected. Check your transactions later.');
// Optionally: poll status, log to analytics, or navigate away
}
Scenario: wait for a filtered value
A payment screen emits order status events. Wait for the order to reach
confirmed status before showing the success toast:
Future<void> placeOrder() async {
EventBusGlobal.event(onPlaceOrder).emit(order);
final confirmed = await EventBusGlobal.event(onOrderStatus).waitFor(
where: (status, _) => status == OrderStatus.confirmed,
timeout: Duration(seconds: 30),
);
showSuccessToast('Order $confirmed is confirmed!');
}
Scenario: subEvent + waitFor
Only wait for even counter values:
final evenCount = await EventBusGlobal.subEvent(evenSecureInt).waitFor(
timeout: Duration(seconds: 5),
);
print('First even number: $evenCount');
Scenario: from a plain Dart service
class PaymentService {
Future<PaymentResult> processPayment(Payment payment) async {
EventBusGlobal.event(onPaymentInitiated).emit(payment);
final result = await EventBusGlobal.event(onPaymentResult).waitFor(
where: (result, _) => result.paymentId == payment.id,
timeout: Duration(seconds: 60),
);
return result;
}
}
Scenario: wait for an emission with metadata
Use waitForWithMeta when you also need the BusMetadata (timestamp, source,
extraData):
final (user, meta) = await EventBusGlobal.event(onUserLogin).waitForWithMeta(
timeout: Duration(seconds: 10),
where: (u, _) => u.isVerified,
);
print('Logged in at ${meta.timestamp}');
print('Source: ${meta.source}');
Behaviour reference:
| Aspect | Behaviour |
|---|---|
| Returns | Future<T> — completes with the value of the first emission after the call |
| Meta variant | waitForWithMeta() returns Future<(T, BusMetadata)> — available on events, subEvents, and the global API |
| Timeout | Defaults to 30s; throws a TimeoutException. Pass timeout: null to wait indefinitely |
where |
Optional predicate that must return true for the future to resolve |
| Sticky | Does not resolve with previously emitted values (no sticky behavior) |
22. Reset the bus
EventBusSingleton holds the single core instance shared by the whole app. In
tests you can reset it to start each test with a clean bus:
import 'package:event_bus_global/src/event_bus_singleton.dart';
setUp(() {
EventBusSingleton.reset(); // fresh bus for each test
});
For a full wipe during app runtime, use
EventBusGlobal.clearAll() instead.
Package structure
lib/
├── event_bus_global.dart # exports
└── src/
├── event_bus_definitions.dart # EventBusCore + typedefs
├── event_bus_global.dart # EventBusGlobal singleton facade
├── event_bus_singleton.dart # core singleton (reset for tests)
├── event_bus_action.dart # EventBusAction + mixins (full API)
├── event_bus_action_for_global.dart # EventBusActionForGlobal / SubEventActionForGlobal
├── sub_event_action.dart # SubEventAction + mixins
├── event_bus_builder.dart # EventBusBuilder widget
├── event_bus_identifier.dart # EventBusIdentifier<T>
├── sub_event_identifier.dart # SubEventIdentifier<T>
├── event_bus_identifier_base.dart
├── listener_disposable.dart # ListenerDisposable / ListenerDispatcher
└── bus_metadata.dart # BusMetadata / ValueWithMeta / LogEntry
Additional information
This is a port of event_bus_riverpod.
Run tests with:
flutter test
See the /example folder for a runnable counter app.