flutter
/

BLoC Event Transformers: Debounce, Throttle & Advanced Event Handling

Last Sync: Today

On this page

14
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

BLoC Event Transformers: Debounce, Throttle & Advanced Event Handling

What Are BLoC Event Transformers?

Event transformers in flutter_bloc allow you to control how incoming events are processed by the BLoC. By default, events are processed sequentially: each event is passed to mapEventToState or the event handler, and the next event waits until the previous one completes. Event transformers let you modify this behavior – you can debounce rapid events, throttle them, or even process them concurrently. They are a powerful tool for optimizing user experience and preventing unnecessary state changes.

Why Use Event Transformers?

  • Debounce – Wait for a pause before processing (ideal for search inputs).
  • Throttle – Limit processing to at most once per interval (great for button taps or scroll events).
  • Concurrent – Process events in parallel (e.g., multiple file uploads).
  • Sequential – Default behavior; ensure order but can block.
  • Custom Logic – Combine transformers or implement custom flow control.

Setting Up Dependencies

Add flutter_bloc and optionally rxdart for advanced transformers like debounce and throttle.

YAMLRead-only
1
dependencies:
  flutter_bloc: ^8.1.5
  rxdart: ^0.28.0
  equatable: ^2.0.5

Basic Usage: EventTransformer in on<Event>

In modern flutter_bloc (v8+), you can attach a transformer directly to an event handler using the transformer parameter. The transformer is a function that takes a Stream<Event> and returns a Stream<Event>.

DARTRead-only
1
class SearchBloc extends Bloc<SearchEvent, SearchState> {
  SearchBloc() : super(SearchInitial()) {
    on<SearchQueryChanged>(
      _onSearchQueryChanged,
      transformer: debounce(const Duration(milliseconds: 500)), // custom transformer
    );
  }

  Future<void> _onSearchQueryChanged(
    SearchQueryChanged event,
    Emitter<SearchState> emit,
  ) async {
    // perform search
  }
}

Debounce Transformer

Debouncing ensures that only the last event after a quiet period is processed. This is perfect for search boxes where you want to wait until the user stops typing.

DARTRead-only
1
import 'package:rxdart/rxdart.dart';

EventTransformer<Event> debounce<Event>(Duration duration) {
  return (events, mapper) => events.debounceTime(duration).flatMap(mapper);
}

// Usage
on<SearchQueryChanged>(
  _onSearchQueryChanged,
  transformer: debounce(const Duration(milliseconds: 500)),
);

Throttle Transformer

Throttling limits the rate of processing: at most one event per time window. Useful for buttons that should not trigger too fast (e.g., like button, refresh button).

DARTRead-only
1
EventTransformer<Event> throttle<Event>(Duration duration) {
  return (events, mapper) => events.throttleTime(duration).flatMap(mapper);
}

// Usage
on<LikeButtonPressed>(
  _onLikeButtonPressed,
  transformer: throttle(const Duration(seconds: 1)),
);

Concurrent Processing

By default, events are processed sequentially (each handler waits for the previous to complete). For operations that can run in parallel, you can use concurrent transformer.

DARTRead-only
1
import 'package:flutter_bloc/flutter_bloc.dart';

on<UploadFile>(
  _onUploadFile,
  transformer: concurrent(),
);

Custom Transformer: Combining Debounce and Throttle

You can combine transformers using RxDart operators. For example, you may want to debounce but also ensure that a stale event is not kept forever.

DARTRead-only
1
EventTransformer<Event> debounceAndThrottle<Event>({
  required Duration debounceDuration,
  required Duration throttleDuration,
}) {
  return (events, mapper) => events
      .debounceTime(debounceDuration)
      .throttleTime(throttleDuration)
      .flatMap(mapper);
}

Built‑in Transformers in flutter_bloc

flutter_bloc provides a few built‑in transformers: sequential(), concurrent(), and droppable(). You can import them from package:flutter_bloc/flutter_bloc.dart.

DARTRead-only
1
import 'package:flutter_bloc/flutter_bloc.dart';

// Default – events processed one after another
on<MyEvent>(_onMyEvent, transformer: sequential());

// Concurrent – new events start even if previous is still running
on<MyEvent>(_onMyEvent, transformer: concurrent());

// Droppable – if an event is already being processed, new events are dropped
on<MyEvent>(_onMyEvent, transformer: droppable());

Comparison of Transformers

TransformerBehaviorUse Case
sequential (default)Events processed one by one, waiting for completionAny ordered operations, form submissions
concurrentNew events start immediately, without waiting for previousIndependent tasks like multiple file uploads
droppableIf an event is being processed, new ones are droppedIdempotent actions like 'refresh' where only the first matters
debounceDelays processing until a pause occursSearch input, real‑time filtering
throttleAt most one event per time windowButton taps, scroll events, rate limiting

Real‑World Example: Search with Debounce

Here’s a complete example of a search bloc with debounce and error handling.

DARTRead-only
1
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:rxdart/rxdart.dart';
import 'package:equatable/equatable.dart';

// States
abstract class SearchState extends Equatable {
  const SearchState();
  @override List<Object?> get props => [];
}
class SearchInitial extends SearchState {}
class SearchLoading extends SearchState {}
class SearchSuccess extends SearchState {
  final List<String> results;
  const SearchSuccess(this.results);
  @override List<Object?> get props => [results];
}
class SearchError extends SearchState {
  final String message;
  const SearchError(this.message);
  @override List<Object?> get props => [message];
}

// Events
abstract class SearchEvent extends Equatable {
  const SearchEvent();
  @override List<Object?> get props => [];
}
class SearchQueryChanged extends SearchEvent {
  final String query;
  const SearchQueryChanged(this.query);
  @override List<Object?> get props => [query];
}

// BLoC
class SearchBloc extends Bloc<SearchEvent, SearchState> {
  final SearchRepository repository;

  SearchBloc({required this.repository}) : super(SearchInitial()) {
    on<SearchQueryChanged>(
      _onSearchQueryChanged,
      transformer: debounce(const Duration(milliseconds: 500)),
    );
  }

  Future<void> _onSearchQueryChanged(
    SearchQueryChanged event,
    Emitter<SearchState> emit,
  ) async {
    if (event.query.isEmpty) {
      emit(SearchInitial());
      return;
    }
    emit(SearchLoading());
    try {
      final results = await repository.search(event.query);
      emit(SearchSuccess(results));
    } catch (e) {
      emit(SearchError(e.toString()));
    }
  }
}

// Transformer helper
EventTransformer<Event> debounce<Event>(Duration duration) {
  return (events, mapper) => events.debounceTime(duration).flatMap(mapper);
}

Best Practices

  • Use transformers only when needed – Default sequential is fine for most cases. Add transformers for high‑frequency events.
  • Keep transformers pure – Avoid side effects inside transformers; they should only modify the event stream.
  • Combine transformers with caution – The order of operators matters (e.g., debounce before throttle).
  • Test event processing – Use bloc_test to verify that events are transformed as expected (e.g., only last debounced event triggers state change).
  • Handle errors inside event handlers – Transformers don’t catch errors; errors will still stop the stream unless caught in the handler.
  • Prefer concurrent for independent tasks – Avoid blocking UI for long‑running operations that can run in parallel.

Common Mistakes

  • ❌ Applying debounce to the wrong event – e.g., debouncing a login button leads to slow response. ✅ Use throttle for buttons, debounce for search.
  • ❌ Not disposing of subscriptions – Transformers using RxDart need no manual disposal; the bloc handles it, but ensure you don’t create long‑living streams in the transformer itself.
  • ❌ Forgetting to import rxdart when using debounceTime – This causes compile errors.
  • ❌ Using concurrent for state that depends on order – Concurrent events may cause race conditions; use sequential for ordered operations.
  • ❌ Assuming transformer affects all events – Each on<> can have its own transformer; they don’t share across different event types.

Conclusion

Event transformers give you fine‑grained control over event flow in your BLoCs. They help improve performance, reduce unnecessary state changes, and create a smoother user experience. Whether you need debounce for search, throttle for buttons, or concurrent processing for independent tasks, transformers are an essential tool in any Flutter developer’s BLoC toolkit.

Try it yourself

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:rxdart/rxdart.dart';
import 'package:equatable/equatable.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'BLoC Event Transformers',
      home: BlocProvider(
        create: (_) => SearchBloc(repository: MockSearchRepository()),
        child: SearchPage(),
      ),
    );
  }
}

// ---------- STATES ----------
abstract class SearchState extends Equatable {
  const SearchState();
  @override
  List<Object?> get props => [];
}

class SearchInitial extends SearchState {}

class SearchLoading extends SearchState {}

class SearchSuccess extends SearchState {
  final List<String> results;
  const SearchSuccess(this.results);
  @override
  List<Object?> get props => [results];
}

class SearchError extends SearchState {
  final String message;
  const SearchError(this.message);
  @override
  List<Object?> get props => [message];
}

// ---------- EVENTS ----------
abstract class SearchEvent extends Equatable {
  const SearchEvent();
  @override
  List<Object?> get props() => [];
}

class SearchQueryChanged extends SearchEvent {
  final String query;
  const SearchQueryChanged(this.query);
  @override
  List<Object?> get props => [query];
}

// ---------- TRANSFORMER ----------
EventTransformer<Event> debounce<Event>(Duration duration) {
  return (events, mapper) => events.debounceTime(duration).flatMap(mapper);
}

// ---------- BLOC ----------
class SearchBloc extends Bloc<SearchEvent, SearchState> {
  final SearchRepository repository;

  SearchBloc({required this.repository}) : super(SearchInitial()) {
    on<SearchQueryChanged>(
      _onSearchQueryChanged,
      transformer: debounce(const Duration(milliseconds: 500)),
    );
  }

  Future<void> _onSearchQueryChanged(
    SearchQueryChanged event,
    Emitter<SearchState> emit,
  ) async {
    if (event.query.isEmpty) {
      emit(SearchInitial());
      return;
    }
    emit(SearchLoading());
    try {
      final results = await repository.search(event.query);
      emit(SearchSuccess(results));
    } catch (e) {
      emit(SearchError(e.toString()));
    }
  }
}

// ---------- REPOSITORY ----------
abstract class SearchRepository {
  Future<List<String>> search(String query);
}

class MockSearchRepository implements SearchRepository {
  @override
  Future<List<String>> search(String query) async {
    // Simulate network delay
    await Future.delayed(const Duration(milliseconds: 300));
    if (query.startsWith('error')) {
      throw Exception('Mock error');
    }
    return [
      '$query result 1',
      '$query result 2',
      '$query result 3',
    ];
  }
}

// ---------- UI ----------
class SearchPage extends StatelessWidget {
  final TextEditingController controller = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Search with Debounce')),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            TextField(
              controller: controller,
              decoration: InputDecoration(
                labelText: 'Search (500ms debounce)',
                prefixIcon: Icon(Icons.search),
                border: OutlineInputBorder(),
              ),
              onChanged: (value) {
                context.read<SearchBloc>().add(SearchQueryChanged(value));
              },
            ),
            const SizedBox(height: 20),
            Expanded(
              child: BlocBuilder<SearchBloc, SearchState>(
                builder: (context, state) {
                  if (state is SearchInitial) {
                    return Center(child: Text('Start typing...'));
                  } else if (state is SearchLoading) {
                    return Center(child: CircularProgressIndicator());
                  } else if (state is SearchSuccess) {
                    return ListView.builder(
                      itemCount: state.results.length,
                      itemBuilder: (_, index) => ListTile(
                        title: Text(state.results[index]),
                      ),
                    );
                  } else if (state is SearchError) {
                    return Center(child: Text('Error: ${state.message}'));
                  }
                  return Container();
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Test Your Knowledge

Q1
of 4

Which transformer would you use to prevent a button from being pressed too quickly?

A
debounce
B
throttle
C
concurrent
D
sequential
Q2
of 4

What does the debounce transformer do?

A
Processes events at most once per time window
B
Delays processing until events pause for a specified duration
C
Processes events in parallel
D
Drops events while another is being processed
Q3
of 4

Which package is required to use debounceTime in an event transformer?

A
flutter_bloc
B
rxdart
C
bloc_concurrency
D
async
Q4
of 4

What is the default event transformer in flutter_bloc?

A
concurrent
B
droppable
C
sequential
D
debounce

Frequently Asked Questions

Do I need RxDart to use event transformers?

For basic sequential, concurrent, and droppable, you don’t need RxDart – they are built into flutter_bloc. For debounce, throttle, or custom transformations, you’ll need RxDart (or you can implement your own with StreamTransformer).

Can I use multiple transformers on the same event handler?

Yes, by composing them using RxDart operators. For example, debounce followed by throttle. Just be careful with the order.

What happens if an event handler throws an error with a transformer?

The error will be emitted and the stream will continue (unless you use async* with try‑catch). Transformers don’t catch errors; you must handle them inside the event handler.

Can I use transformers with Cubits?

No, Cubits don’t have event handlers. Transformers are specific to BLoCs (using on<Event>). For Cubits, you’d need to handle debouncing/throttling manually in your UI or use a separate service.

How do I test that debounce is working correctly?

Use blocTest and simulate rapid events. You can also use fakeAsync from the fake_async package to control time. Verify that only the last event triggers state changes after the debounce delay.

Previous

bloc stream transform

Next

bloc concurrency

Related Content

Need help?

Explore our comprehensive docs or start a chat with our tech experts.