flutter
/

BLoC Stream Transform: Event Transformers & Reactive Streams

Last Sync: Today

On this page

12
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

BLoC Stream Transform: Event Transformers & Reactive Streams

What Are Event Transformers in BLoC?

Event transformers are a powerful feature in flutter_bloc that allow you to modify the stream of events before they are processed by your BLoC. They give you control over how events are queued, delayed, filtered, or transformed – enabling advanced scenarios like debouncing search inputs, throttling button presses, or deduplicating events. Transformers are applied using the transformer parameter in the on<Event> method.

Why Use Event Transformers?

  • Improve Performance – Prevent unnecessary expensive operations (e.g., API calls) by debouncing rapid events.
  • Enhance User Experience – Throttle fast button taps to avoid multiple submissions.
  • Avoid Duplicate Work – Ignore consecutive identical events with distinct.
  • Control Concurrency – Manage how overlapping events are handled (e.g., cancel previous, restart, or queue).
  • Reduce Noise – Filter out unwanted events before they reach your business logic.

Basic Event Transformer

Every event handler in BLoC can accept a transformer parameter. By default, BLoC uses sequential() from bloc_concurrency, which processes events one at a time. You can override it with custom transformers from rxdart or your own implementation.

DARTRead-only
1
on<SearchEvent>(
  _onSearch,
  transformer: (events, mapper) => events.debounceTime(Duration(milliseconds: 500)).flatMap(mapper),
);

The transformer receives an events stream (the incoming events) and a mapper function that maps each event to a Stream<State>. Your transformer should return a Stream<State> that will be emitted to the BLoC.

Debouncing Search Inputs

Debouncing waits until the user pauses typing before triggering a search. This avoids making an API call on every keystroke.

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

  void _onSearchQueryChanged(SearchQueryChanged event, Emitter<SearchState> emit) async {
    emit(SearchLoading());
    try {
      final results = await searchApi(event.query);
      emit(SearchSuccess(results));
    } catch (e) {
      emit(SearchError(e.toString()));
    }
  }

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

Throttling Button Clicks

Throttling limits the rate at which events are processed, e.g., preventing multiple rapid button taps from submitting the same form multiple times.

DARTRead-only
1
class FormBloc extends Bloc<FormEvent, FormState> {
  FormBloc() : super(FormInitial()) {
    on<SubmitPressed>(
      _onSubmitPressed,
      transformer: throttle(const Duration(seconds: 2)),
    );
  }

  void _onSubmitPressed(SubmitPressed event, Emitter<FormState> emit) async {
    emit(FormSubmitting());
    try {
      await submitForm();
      emit(FormSuccess());
    } catch (e) {
      emit(FormError(e.toString()));
    }
  }

  EventTransformer<T> throttle<T>(Duration duration) {
    return (events, mapper) => events.throttleTime(duration).flatMap(mapper);
  }
}

Distinct (Ignore Consecutive Duplicates)

Sometimes you want to ignore events that are identical to the previous one, especially when the event carries the same data (e.g., same search query).

DARTRead-only
1
class SettingsBloc extends Bloc<SettingsEvent, SettingsState> {
  SettingsBloc() : super(SettingsInitial()) {
    on<ThemeChanged>(
      _onThemeChanged,
      transformer: distinct(),
    );
  }

  void _onThemeChanged(ThemeChanged event, Emitter<SettingsState> emit) async {
    // Only called if event.theme is different from the previous event's theme
    emit(SettingsUpdated(theme: event.theme));
  }

  EventTransformer<T> distinct<T>() {
    return (events, mapper) => events.distinct().flatMap(mapper);
  }
}

Combining Transformers

You can compose multiple transformers. For example, debounce and then distinct to avoid searching for the same query after a pause.

DARTRead-only
1
on<SearchQueryChanged>(
  _onSearchQueryChanged,
  transformer: (events, mapper) => events
      .debounceTime(Duration(milliseconds: 500))
      .distinct()
      .flatMap(mapper),
);

Using bloc_concurrency for Advanced Concurrency

The bloc_concurrency package provides ready‑made transformers like sequential, droppable, restartable, and concurrent. These control how events are queued when a handler is already running.

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

class MyBloc extends Bloc<MyEvent, MyState> {
  MyBloc() : super(MyState()) {
    on<MyEvent>(
      _onEvent,
      transformer: restartable(), // Drops previous and keeps latest
    );
  }

  void _onEvent(MyEvent event, Emitter<MyState> emit) async {
    // long operation
  }
}

Custom Event Transformers

You can build your own transformers by manipulating the event stream. For example, a transformer that ignores events after a certain count.

DARTRead-only
1
EventTransformer<T> takeFirst<T>(int count) {
  return (events, mapper) => events.take(count).flatMap(mapper);
}

// Usage
on<SomeEvent>(_onEvent, transformer: takeFirst(5));

Best Practices

  • Define transformers as reusable functions – Keep them outside the BLoC for easier testing and reuse.
  • Use bloc_concurrency for concurrency control – It’s battle‑tested and covers common patterns.
  • Apply transformers only when needed – Not every event needs transformation; simple events can use the default sequential.
  • Keep transformers simple – Complex stream logic can be extracted into helper functions.
  • Test transformers separately – Verify that your transformer behaves as expected using rxdart’s TestStream.

Common Mistakes

  • ❌ Using debounce without canceling previous work – If you have long‑running operations, a restartable transformer may be better. ✅ Combine debounce with restartable to cancel previous requests.
  • ❌ Applying distinct without proper equality – For custom events, implement == or use Equatable so distinct works correctly.
  • ❌ Forgetting to import rxdart – Many transformers like debounceTime and throttleTime come from rxdart.
  • ❌ Over‑transforming – Adding too many transformations can make the flow hard to debug. ✅ Keep it minimal and documented.

Conclusion

Event transformers give you fine‑grained control over how events are processed in your BLoC. By using debounce, throttle, distinct, and concurrency controls, you can significantly improve app performance and user experience. Combining these transformers with rxdart and bloc_concurrency allows you to handle complex real‑world scenarios elegantly.

Try it yourself

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

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

// Events
abstract class SearchEvent {}
class SearchQueryChanged extends SearchEvent {
  final String query;
  SearchQueryChanged(this.query);
}

// States
abstract class SearchState {}
class SearchInitial extends SearchState {}
class SearchLoading extends SearchState {}
class SearchSuccess extends SearchState {
  final List<String> results;
  SearchSuccess(this.results);
}
class SearchError extends SearchState {
  final String message;
  SearchError(this.message);
}

// BLoC with debounced transformer
class SearchBloc extends Bloc<SearchEvent, SearchState> {
  SearchBloc() : super(SearchInitial()) {
    on<SearchQueryChanged>(
      _onSearchQueryChanged,
      transformer: debounce(const Duration(milliseconds: 500)),
    );
  }

  void _onSearchQueryChanged(SearchQueryChanged event, Emitter<SearchState> emit) async {
    emit(SearchLoading());
    // Simulate an API call
    await Future.delayed(Duration(milliseconds: 300));
    if (event.query.isEmpty) {
      emit(SearchSuccess([]));
    } else {
      final results = List.generate(5, (i) => '${event.query} result $i');
      emit(SearchSuccess(results));
    }
  }

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

// UI
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: BlocProvider(
        create: (_) => SearchBloc(),
        child: SearchPage(),
      ),
    );
  }
}

class SearchPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Debounced Search')),
      body: Padding(
        padding: EdgeInsets.all(16),
        child: Column(
          children: [
            TextField(
              decoration: InputDecoration(
                labelText: 'Search',
                border: OutlineInputBorder(),
              ),
              onChanged: (query) => context.read<SearchBloc>().add(SearchQueryChanged(query)),
            ),
            SizedBox(height: 20),
            Expanded(
              child: BlocBuilder<SearchBloc, SearchState>(
                builder: (context, state) {
                  if (state is SearchLoading) {
                    return Center(child: CircularProgressIndicator());
                  } else if (state is SearchSuccess) {
                    if (state.results.isEmpty) {
                      return Center(child: Text('No results'));
                    }
                    return ListView.builder(
                      itemCount: state.results.length,
                      itemBuilder: (_, i) => ListTile(title: Text(state.results[i])),
                    );
                  } else if (state is SearchError) {
                    return Center(child: Text('Error: ${state.message}'));
                  }
                  return Container();
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Test Your Knowledge

Q1
of 3

Which transformer would you use to wait for the user to stop typing before triggering a search?

A
throttle
B
debounce
C
distinct
D
restartable
Q2
of 3

What package provides the `debounceTime` and `throttleTime` operators used in event transformers?

A
async
B
bloc
C
rxdart
D
stream_transform
Q3
of 3

How do you apply a custom transformer to a specific event handler in a BLoC?

A
By overriding the `transformEvents` method
B
By passing a `transformer` parameter to `on<Event>`
C
By wrapping the event in a `TransformedEvent`
D
By using a `StreamTransformer` globally

Frequently Asked Questions

What is the difference between debounce and throttle?

Debounce waits for a pause in events before processing the last one. Throttle processes the first event and then ignores subsequent ones for a period. Debounce is great for search inputs; throttle is ideal for rate‑limiting button taps.

Do I need to add rxdart to use event transformers?

Yes, most transformers like debounceTime, throttleTime, and distinct come from rxdart. You can also write custom transformers without rxdart using StreamTransformer but it’s more verbose.

How do I cancel a previous request when a new event arrives?

Use a restartable transformer from bloc_concurrency. It cancels the previous handler and starts a new one with the latest event.

Can I apply different transformers to different events in the same BLoC?

Yes, each on<Event> registration can have its own transformer parameter, allowing per‑event customization.

How do I test an event transformer?

You can test by feeding events into a StreamController and using rxdart’s TestStream to verify the output order and timing.

Previous

bloc streams

Next

bloc event transformers

Related Content

Need help?

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