flutter_event_limiter 1.0.0
flutter_event_limiter: ^1.0.0 copied to clipboard
A powerful event handling library. Prevents double-clicks, fixes async race conditions, and manages loading states with simple Builders and Wrappers. Production-ready with zero known bugs.
Flutter Event Limiter ๐ก๏ธ #
Stop Spam Clicks. Fix Race Conditions. Prevent Memory Leaks.
A production-ready library to handle Throttling (anti-spam) and Debouncing (search APIs) with built-in safety checks.
๐ Why use this? #
Standard Flutter InkWell or Timer usually leads to these bugs:
- โ Double Click Crash: User taps "Submit" twice โ API calls twice โ Database error.
- โ Race Conditions: User types "a", then "ab". API "a" returns after "ab" โ UI shows wrong result.
- โ Memory Leaks:
setState() called after dispose()when the API returns late.
โ This library fixes ALL of them automatically.
Comparison with Other Libraries #
| Feature | flutter_event_limiter |
easy_debounce |
rxdart |
|---|---|---|---|
| Prevents Double Clicks | โ | โ | โ ๏ธ (Complex) |
| Fixes Race Conditions | โ (Auto-cancel) | โ | โ |
Auto mounted Check |
โ (Safe setState) | โ | โ |
| Universal Builder | โ (Works with ANY widget) | โ | โ |
| Loading State Management | โ (Built-in) | โ | โ |
| Zero Boilerplate | โ | โ | โ |
| Memory Leak Prevention | โ (Auto-dispose) | โ ๏ธ (Manual) | โ ๏ธ (Manual) |
๐ฆ Installation #
Add to your pubspec.yaml:
dependencies:
flutter_event_limiter: ^1.0.0
Then run:
flutter pub get
Import:
import 'package:flutter_event_limiter/flutter_event_limiter.dart';
๐ฅ Quick Start #
1. Prevent Double Clicks (Throttling) #
Wrap your button. That's it. It ignores clicks for 500ms (configurable) after the first one.
ThrottledInkWell(
onTap: () => submitOrder(), // ๐ Safe! Only runs once per 500ms
onDoubleTap: () => handleDoubleTap(), // โ
Also throttled!
onLongPress: () => showMenu(), // โ
Also throttled!
child: Container(
padding: EdgeInsets.all(12),
child: Text("Submit Order"),
),
)
Result: No matter how fast the user clicks, submitOrder() only runs once every 500ms.
2. Search API with Auto-Cancel (Async Debouncing) #
Perfect for search bars. It waits for the user to stop typing, and automatically cancels previous pending API calls to prevent UI flickering.
AsyncDebouncedTextController<List<User>>(
// 1. Auto-waits 300ms after user stops typing
// 2. Auto-cancels previous request if user keeps typing
onChanged: (text) async => await api.searchUsers(text),
// 3. Auto-checks 'mounted' before calling this
onSuccess: (users) => setState(() => _users = users),
// 4. Handles errors gracefully
onError: (error, stack) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Search failed: $error')),
);
},
// 5. Manages loading state automatically
onLoadingChanged: (isLoading) => setState(() => _loading = isLoading),
)
What it does:
- User types "a" โ Timer starts (300ms)
- User types "ab" โ Previous timer cancelled, new timer starts
- User stops typing โ After 300ms, API call starts
- User types "abc" while "ab" API is running โ "ab" result is ignored, only "abc" result is used
Result: Zero race conditions, zero memory leaks, smooth UX.
3. Form Submit with Loading State #
Prevents double-submission and provides loading state out of the box.
AsyncThrottledCallbackBuilder(
onPressed: () async {
await api.uploadFile(); // ๐ Button stays locked until this finishes
Navigator.pop(context); // โ
Auto-checks mounted before navigation
},
onError: (error, stack) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Upload failed: $error')),
);
},
builder: (context, callback, isLoading) {
return ElevatedButton(
onPressed: isLoading ? null : callback, // Disable when loading
child: isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text("Upload"),
);
},
)
Result: Button disabled during upload, automatic error handling, zero boilerplate.
๐งฉ Advanced Usage: The "Builder" Pattern #
Don't like our wrappers? Want to use a custom GestureDetector, FloatingActionButton, or Slider? Use the Builders. They give you a "wrapped" callback to use anywhere.
Universal Throttler (For ANY Widget) #
ThrottledBuilder(
duration: Duration(seconds: 1),
builder: (context, throttle) {
return FloatingActionButton(
// Wrap your callback with 'throttle()'
onPressed: throttle(() => saveData()),
child: Icon(Icons.save),
);
},
)
Universal Debouncer (For ANY Widget) #
DebouncedBuilder(
duration: Duration(milliseconds: 500),
builder: (context, debounce) {
return Slider(
value: _volume,
onChanged: (value) => debounce(() {
setState(() => _volume = value);
api.updateVolume(value);
}),
);
},
)
Universal Async Throttle (For Custom Async Operations) #
AsyncThrottledBuilder(
maxDuration: Duration(seconds: 30), // Timeout for long operations
builder: (context, throttle) {
return CustomButton(
onPressed: throttle(() async {
try {
await api.processLargeFile();
if (!mounted) return; // โ
Check mounted before context usage
Navigator.pop(context);
} catch (e) {
if (!mounted) return;
showErrorDialog(context, e);
}
}),
);
},
)
Note: For async builders without automatic error handling, you must handle errors manually with try-catch.
๐ Complete Widget Reference #
Throttling (Prevent Spam Clicks) #
| Widget | Use Case | Features |
|---|---|---|
ThrottledInkWell |
Basic buttons with ripple | onTap, onDoubleTap, onLongPress |
ThrottledTapWidget |
Buttons without ripple | Custom GestureDetector |
ThrottledCallback |
Custom callback wrapper | Used by BaseButton |
ThrottledBuilder |
Universal (works with ANY widget) | Maximum flexibility |
Throttler |
Direct class usage (advanced) | Access to isThrottled, reset() |
Debouncing (Search, Auto-save) #
| Widget | Use Case | Features |
|---|---|---|
DebouncedTextController |
Text input debouncing | Sync callback, manual mounted check |
AsyncDebouncedTextController |
Search API with loading state | Auto-cancel, loading state, error handling |
DebouncedCallback |
Custom callback wrapper | Sync operations |
DebouncedBuilder |
Universal (works with ANY widget) | Maximum flexibility |
Debouncer |
Direct class usage (advanced) | Access to flush(), cancel() |
Async Operations (Form Submit, File Upload) #
| Widget | Use Case | Features |
|---|---|---|
AsyncThrottledCallbackBuilder |
Form submit with loading state | Auto loading, error handling, mounted check |
AsyncThrottledCallback |
Form submit (manual mounted check) | Simple wrapper |
AsyncThrottledBuilder |
Universal async throttle | Maximum flexibility |
AsyncDebouncedCallbackBuilder |
Search with loading state | Auto-cancel, loading state, error handling |
AsyncDebouncedCallback |
Search (manual mounted check) | Simple wrapper |
AsyncDebouncedBuilder |
Universal async debounce | Maximum flexibility |
AsyncDebouncer |
Direct class usage (advanced) | ID-based cancellation, Future<T?> |
AsyncThrottler |
Direct class usage (advanced) | Process-based locking, timeout |
High-Frequency Events (Scroll, Resize) #
| Widget | Use Case | Features |
|---|---|---|
HighFrequencyThrottler |
60fps events (scroll, mouse move) | DateTime-based, zero Timer overhead |
โ ๏ธ Comparison: The Old Way vs The New Way #
โ The Old Way (Bad) #
// Manually handling timer and cleanup... nightmare!
Timer? _timer;
bool _isLoading = false;
void onSearch(String text) {
_timer?.cancel();
_timer = Timer(Duration(milliseconds: 300), () async {
setState(() => _isLoading = true);
try {
final result = await api.search(text);
if (!mounted) return; // Must remember this!
setState(() {
_result = result;
_isLoading = false;
});
} catch (e) {
if (!mounted) return; // Must remember this again!
setState(() => _isLoading = false);
// Handle error...
}
});
}
@override
void dispose() {
_timer?.cancel(); // Must remember this!
super.dispose();
}
Problems:
- 15+ lines of boilerplate
- Easy to forget
mountedcheck โ crash - Easy to forget
dispose()โ memory leak - Manual loading state management
- Manual error handling
โ The New Way (Good) #
// Zero boilerplate. Auto-dispose. Auto-mounted check. Auto-loading state.
AsyncDebouncedTextController(
onChanged: (text) async => await api.search(text),
onSuccess: (result) => setState(() => _result = result),
onError: (e, stack) => showErrorDialog(e),
onLoadingChanged: (loading) => setState(() => _isLoading = loading),
)
Benefits:
- 4 lines of code
- Automatic
mountedcheck - Automatic
dispose() - Automatic loading state
- Automatic error handling
- Automatic race condition prevention
๐ Understanding Throttle vs Debounce #
Throttle (Fire Immediately, Then Block) #
User clicks: โผ โผ โผโผโผ โผ
Executes: โ X X X โ
|<-500ms->| |<-500ms->|
Use for: Button clicks, scroll events, resize events
Debounce (Wait for Pause, Then Fire) #
User types: a b c d ... (pause) ... e f g
Executes: โ โ
|<--300ms wait-->| |<--300ms wait-->|
Use for: Search input, auto-save, real-time validation
AsyncDebouncer (Debounce + Auto-Cancel) #
User types: a b c (API for 'abc' starts) ... d
API calls: X X โผ (running...) X (result ignored)
|<-wait->| |<-wait->|
Result used: โ (only 'd')
Use for: Search APIs, autocomplete, async validation
๐ง Advanced Features #
1. Custom Durations #
// Throttle with 1 second window
ThrottledInkWell(
duration: Duration(seconds: 1),
onTap: () => submit(),
child: Text('Submit'),
)
// Debounce with 500ms delay
AsyncDebouncedTextController(
duration: Duration(milliseconds: 500),
onChanged: (text) async => await api.search(text),
)
2. Reset Throttle Manually #
final throttler = Throttler();
// Use throttler
InkWell(
onTap: throttler.wrap(() => handleTap()),
child: Text('Tap me'),
)
// Reset throttle (allow immediate next call)
throttler.reset();
// Check state
if (throttler.isThrottled) {
print('Currently blocked');
}
3. Flush Debouncer (Execute Immediately) #
final controller = DebouncedTextController(
onChanged: (text) => search(text),
);
// User presses Enter โ Execute immediately without waiting
onSubmit() {
controller.flush(); // Cancels timer and executes now
}
4. Manual Cancel #
final debouncer = AsyncDebouncer();
// Start debounced operation
debouncer.run(() async => await api.call());
// Cancel all pending operations
debouncer.cancel();
๐ Common Pitfalls #
1. โ Forgetting Mounted Check with Builder Widgets #
// โ BAD - AsyncDebouncedBuilder doesn't auto-check mounted
AsyncDebouncedBuilder(
builder: (context, debounce) {
return TextField(
onChanged: (text) => debounce(() async {
final result = await api.search(text);
setState(() => _result = result); // โ Crash if unmounted!
}),
);
},
)
// โ
GOOD - Always check mounted
AsyncDebouncedBuilder(
builder: (context, debounce) {
return TextField(
onChanged: (text) => debounce(() async {
final result = await api.search(text);
if (!mounted) return; // โ
Safe!
setState(() => _result = result);
}),
);
},
)
// โ
BETTER - Use AsyncDebouncedCallbackBuilder for auto-check
AsyncDebouncedCallbackBuilder(
onChanged: (text) async => await api.search(text),
onSuccess: (result) => setState(() => _result = result), // โ
Auto-checks mounted!
builder: (context, callback, isLoading) => TextField(onChanged: callback),
)
2. โ Not Handling Null Results from AsyncDebouncer #
// โ BAD - Null check missing
final result = await asyncDebouncer.run(() async => await api.call());
processResult(result); // โ Crash if result is null (cancelled)
// โ
GOOD - Check for cancellation
final result = await asyncDebouncer.run(() async => await api.call());
if (result == null) return; // Cancelled by newer call
processResult(result); // โ
Safe!
3. โ Providing Both controller and initialValue #
// โ BAD - Will throw assertion error
DebouncedTextController(
controller: myController,
initialValue: "test", // โ Conflict!
onChanged: (text) => search(text),
)
// โ
GOOD - Use controller only
final controller = TextEditingController(text: "initial");
DebouncedTextController(
controller: controller,
onChanged: (text) => search(text),
)
// โ
GOOD - Use initialValue only
DebouncedTextController(
initialValue: "initial",
onChanged: (text) => search(text),
)
๐งช Testing #
Unit tests coming soon! (Contributions welcome)
๐ License #
MIT License. See LICENSE file for details.
๐ค Contributing #
Contributions are welcome! Please:
- Fork the repository
- Create a feature branch
- Add tests for your changes
- Ensure all tests pass
- Submit a pull request
๐ฎ Support #
- ๐ง Issues: GitHub Issues
- ๐ฌ Discussions: GitHub Discussions
- โญ Star this repo if you find it useful!
๐ฏ Roadmap #
- โ Unit tests (60+ test cases)
- โ Integration tests with example app
- โ Performance benchmarks
- โ Video tutorials
- โ More examples (e-commerce, chat app, etc.)
Made with โค๏ธ for the Flutter community