Transactions
Batch multiple operations into a single notification for better performance and atomic updates.
Overview
Transactions allow you to make multiple changes to a reactive collection while triggering only one notification at the end. This is useful for:
- Performance - Reduce UI rebuilds from multiple operations
- Atomic updates - Ensure all changes complete before listeners are notified
- Cleaner code - Explicit batching of related operations
Basic Usage
void main() {
final products = ListNotifier<Product>(data: []);
// Listen to changes
products.listen((list, _) => print('Products updated: ${list.length} items'));
final product1 = Product(id: '1', name: 'Widget', price: 9.99);
final product2 = Product(id: '2', name: 'Gadget', price: 19.99);
final product3 = Product(id: '3', name: 'Doohickey', price: 29.99);
print('--- Without transaction: 3 notifications ---');
products.add(product1); // Notification 1
products.add(product2); // Notification 2
products.add(product3); // Notification 3
products.clear();
print('\n--- With transaction: 1 notification ---');
products.startTransAction();
products.add(product1); // No notification
products.add(product2); // No notification
products.add(product3); // No notification
products.endTransAction(); // Single notification for all 3 adds
}How Transactions Work
When you call startTransAction():
- The
_inTransactionflag is set totrue - All mutation operations update the collection but don't notify listeners
- The
_hasChangedflag tracks whether any actual changes occurred - When
endTransAction()is called, a single notification fires (if changes occurred)
final items = ListNotifier<int>();
items.listen((list, _) => print('Notification: $list'));
// Without transaction: 3 notifications
items.add(1); // Notification 1
items.add(2); // Notification 2
items.add(3); // Notification 3
items.clear();
// With transaction: 1 notification
items.startTransAction();
items.add(1); // No notification
items.add(2); // No notification
items.add(3); // No notification
items.endTransAction(); // Single notification with [1, 2, 3]Use Cases
1. Bulk Loading Data
Load multiple items without triggering notifications for each one:
final products = ListNotifier<Product>();
products.listen((list, _) => rebuildUI());
void loadProducts(List<Product> data) {
products.startTransAction();
products.clear();
products.addAll(data);
products.endTransAction(); // Single UI rebuild
}2. Atomic State Updates
Ensure related changes happen together:
final cart = ListNotifier<CartItem>();
void updateItemQuantity(String itemId, int newQuantity) {
cart.startTransAction();
final index = cart.indexWhere((item) => item.id == itemId);
if (index != -1) {
if (newQuantity <= 0) {
cart.removeAt(index);
} else {
final item = cart[index];
cart[index] = CartItem(item.id, item.name, newQuantity, item.price);
}
}
cart.endTransAction(); // Single notification for the complete operation
}3. Multiple Related Operations
Batch operations that should be seen as a single logical change:
final todos = ListNotifier<Todo>();
void moveTodo(int fromIndex, int toIndex) {
todos.startTransAction();
final todo = todos.removeAt(fromIndex);
todos.insert(toIndex, todo);
todos.endTransAction(); // Single notification
}4. Conditional Batching
Complex logic with multiple paths:
final items = ListNotifier<String>();
void processUpdates(List<String> updates) {
items.startTransAction();
for (final update in updates) {
if (shouldAdd(update)) {
items.add(update);
} else if (shouldRemove(update)) {
items.remove(update);
} else if (shouldUpdate(update)) {
final index = items.indexOf(update);
if (index != -1) {
items[index] = update;
}
}
}
items.endTransAction(); // Single notification for all changes
}Transaction Behavior with Notification Modes
Transactions work with all notification modes:
With always Mode (Default)
final items = ListNotifier<String>(
notificationMode: CustomNotifierMode.always,
);
items.startTransAction();
items.add('a');
items.add('b');
items.endTransAction(); // ✅ Notifies (always mode)With normal Mode
final items = ListNotifier<String>(
notificationMode: CustomNotifierMode.normal,
);
items.startTransAction();
items.add('a');
items.add('a'); // Duplicate, no actual change
items.endTransAction(); // ✅ Notifies (something changed)
items.startTransAction();
items.remove('nonexistent'); // No actual change
items.endTransAction(); // ❌ No notification (nothing changed)With manual Mode
final items = ListNotifier<String>(
notificationMode: CustomNotifierMode.manual,
);
items.startTransAction();
items.add('a');
items.add('b');
items.endTransAction(); // ❌ No notification (manual mode)
// Must call notifyListeners() manually even after transaction
items.notifyListeners(); // ✅ Now notifiesLearn more about notification modes →
Nested Transactions
Nested transactions are not allowed and will cause an assertion error:
final items = ListNotifier<int>();
items.startTransAction();
items.add(1);
// ❌ ERROR: Assertion failed
items.startTransAction(); // Can't nest transactions!Why not allowed:
- Simpler implementation
- Clearer code - one transaction at a time
- Avoid confusion about when notifications fire
Alternative: Complete the first transaction before starting another:
void operation1() {
items.startTransAction();
items.add(1);
items.endTransAction();
}
void operation2() {
items.startTransAction();
items.add(2);
items.endTransAction();
}
// Call separately
operation1();
operation2();Transaction Safety
Always End Transactions
Make sure to always call endTransAction(), even if errors occur:
❌ Unsafe:
items.startTransAction();
items.add(data); // Might throw exception
items.endTransAction(); // Might never be called!✅ Safe:
items.startTransAction();
try {
items.add(data);
} finally {
items.endTransAction(); // Always called
}Assertions Help Catch Errors
The implementation includes assertions to help catch mistakes:
// Assertion when starting nested transaction
assert(!_inTransaction, 'Only one transaction at a time');
// Assertion when ending without active transaction
assert(_inTransaction, 'No active transaction');These assertions only fire in debug mode but help catch bugs during development.
Performance Benefits
Without Transactions
final items = ListNotifier<String>();
items.listen((list, _) {
// Expensive UI rebuild
rebuildComplexWidget(list);
});
void loadData(List<String> data) {
for (final item in data) {
items.add(item); // Rebuilds UI for EACH item!
}
}
// Loading 100 items = 100 UI rebuilds!
loadData(List.generate(100, (i) => 'item$i'));With Transactions
final items = ListNotifier<String>();
items.listen((list, _) {
// Expensive UI rebuild
rebuildComplexWidget(list);
});
void loadData(List<String> data) {
items.startTransAction();
for (final item in data) {
items.add(item); // No notification
}
items.endTransAction(); // Single UI rebuild!
}
// Loading 100 items = 1 UI rebuild!
loadData(List.generate(100, (i) => 'item$i'));Performance improvement: From O(n) rebuilds to O(1) rebuild!
Real-World Examples
Example 1: Shopping Cart Checkout
class CheckoutService {
final cart = ListNotifier<CartItem>();
final purchaseHistory = ListNotifier<Purchase>();
Future<void> checkout() async {
cart.startTransAction();
// Create purchase record
final purchase = Purchase(
items: List.from(cart),
total: calculateTotal(cart),
timestamp: DateTime.now(),
);
// Process payment
await processPayment(purchase);
// Add to history
purchaseHistory.add(purchase);
// Clear cart
cart.clear();
cart.endTransAction(); // Single notification after checkout complete
}
}Example 2: Drag and Drop Reordering
class TodoListWidget extends StatefulWidget {
final ListNotifier<Todo> todos;
const TodoListWidget(this.todos, {super.key});
@override
State<TodoListWidget> createState() => _TodoListWidgetState();
}
class _TodoListWidgetState extends State<TodoListWidget> {
void _onReorder(int oldIndex, int newIndex) {
widget.todos.startTransAction();
final todo = widget.todos.removeAt(oldIndex);
widget.todos.insert(newIndex, todo);
widget.todos.endTransAction(); // Single notification for the reorder
}
@override
Widget build(BuildContext context) {
return ReorderableListView(
onReorder: _onReorder,
children: <Widget>[
for (var todo in widget.todos) TodoTile(todo),
],
);
}
}Example 3: Batch Data Sync
class DataSyncService {
final cache = MapNotifier<String, User>();
Future<void> syncUsers() async {
final updates = await fetchUserUpdates();
cache.startTransAction();
for (final update in updates) {
switch (update.type) {
case UpdateType.add:
cache[update.id] = update.user;
break;
case UpdateType.remove:
cache.remove(update.id);
break;
case UpdateType.modify:
cache[update.id] = update.user;
break;
}
}
cache.endTransAction(); // Single notification after all updates
}
}Example 4: Form Bulk Updates
class FormModel {
final fields = MapNotifier<String, String>();
void loadFromJson(Map<String, dynamic> json) {
fields.startTransAction();
fields.clear();
json.forEach((key, value) {
fields[key] = value.toString();
});
fields.endTransAction(); // Single notification
}
void resetToDefaults() {
fields.startTransAction();
fields['name'] = '';
fields['email'] = '';
fields['phone'] = '';
fields['address'] = '';
fields.endTransAction(); // Single notification
}
}Best Practices
1. Use Transactions for Bulk Operations
Any time you're making multiple related changes:
// ✅ Good
items.startTransAction();
for (final item in newItems) {
items.add(item);
}
items.endTransAction();
// ❌ Bad
for (final item in newItems) {
items.add(item); // Notification for each!
}2. Keep Transactions Short
Don't hold transactions open for long periods or across async operations:
// ❌ Bad - transaction held during async operation
items.startTransAction();
items.clear();
await fetchData(); // Long async operation
items.addAll(data);
items.endTransAction();
// ✅ Good - transaction only around sync operations
final data = await fetchData();
items.startTransAction();
items.clear();
items.addAll(data);
items.endTransAction();3. Use try/finally for Safety
Always ensure transactions are ended:
items.startTransAction();
try {
// Operations that might throw
complexOperation();
} finally {
items.endTransAction();
}4. Prefer Transactions Over manual Mode
For batching operations, transactions are clearer than manual mode:
// ✅ Better - works with any notification mode
items.startTransAction();
items.add('a');
items.add('b');
items.endTransAction();
// ❌ Worse - requires manual mode, easy to forget notification
items.add('a');
items.add('b');
items.notifyListeners();Comparison: Transactions vs manual Mode
| Feature | Transactions | manual Mode |
|---|---|---|
| Syntax | startTransAction() / endTransAction() | notifyListeners() |
| Works with any mode | ✅ Yes | ❌ No (requires manual mode) |
| Clear intent | ✅ Explicit batching | ❌ Easy to forget notification |
| Assertions | ✅ Helps catch errors | ❌ No safety checks |
| Recommended | ✅ Yes | ⚠️ Use transactions instead |