With package:bloc, how would you build a debounced city search whose stale responses can never overwrite newer results?
answer
- EventTransformer is just a function
- (events, mapper) returns a stream
- debounce first, then switchMap
- cancelled runs cannot emit
- empty query short-circuits
basics
~20 sGive the query-changed handler a custom EventTransformer that debounces the event stream and then switch-maps it through the mapper, such as events.debounce(300 ms).switchMap(mapper) with stream_transform. The debounce cuts requests; switchMap cancels older runs so their late emits are ignored.
solid answer
~40 sAn `EventTransformer<E>` is a function `(Stream<E> events, EventMapper<E> mapper) => Stream<E>`: bloc hands it the handler's events and a `mapper` that runs the handler for one event. For search, compose `events.debounce(const Duration(milliseconds: 300)).switchMap(mapper)` from stream_transform — the shape the bloc repository's own search example uses — or `restartable<E>().call(events.debounce(d), mapper)` with bloc_concurrency. The debounce waits for typing to pause, so 'L', 'Li', 'Lis' become one request. `switchMap` cancels the previous handler run when a newer one starts; bloc cancels that run's `Emitter`, so a slow 'Li' response emits into nothing. Neither half suffices alone: debounce still lets two runs overlap after a mid-word pause, and restartable alone fires a request per keystroke. In the handler, emit the initial state for an empty query and check `emit.isDone` before costly post-processing.
code
dart · 33 linesimport 'package:bloc/bloc.dart';
import 'package:stream_transform/stream_transform.dart';
EventTransformer<E> debounceRestartable<E>(Duration duration) {
return (events, mapper) => events.debounce(duration).switchMap(mapper);
}
class CitySearchBloc extends Bloc<CitySearchEvent, CitySearchState> {
CitySearchBloc(this._cities) : super(CitySearchInitial()) {
on<CitySearchQueryChanged>(
_onQueryChanged,
transformer: debounceRestartable(const Duration(milliseconds: 300)),
);
}
final CityRepository _cities;
Future<void> _onQueryChanged(
CitySearchQueryChanged event,
Emitter<CitySearchState> emit,
) async {
final query = event.query.trim();
if (query.isEmpty) return emit(CitySearchInitial());
emit(CitySearchInProgress());
try {
final cities = await _cities.search(query);
if (emit.isDone) return; // a newer query took over
emit(CitySearchSuccess(cities));
} catch (_) {
emit(CitySearchFailure('Could not load cities'));
}
}
}go deeper
Recall that on<E> accepts a transformer, and that search needs both a debounce and a way to ignore stale results.
Explain the EventTransformer signature, how debounce and switchMap compose, and what a cancelled Emitter does to late emits.
Show you know the limits: cancellation discards state updates but not network work, why droppable is wrong here, and where emit.isDone saves effort.
Standardise a small set of shared transformers for the codebase so timing policy is consistent and reviewed once, not reinvented per feature.
## The problem A travel app's city search sends a request as the user types. With a plain `on<CitySearchQueryChanged>` handler in **bloc** 9.2: 1. Every keystroke adds an event, and every event starts a request — 'L', 'Li', 'Lis', 'Lisb'. 2. The default transformer runs these handlers **concurrently**. 3. Responses come back in network order, not typing order. If 'Li' is slow, its results arrive after 'Lisb' and overwrite them. Two separate fixes are needed: fewer requests (**debounce**) and no stale writes (**cancel the older run**). ## Anatomy of an EventTransformer `on<E>` takes an optional `transformer` of this type: ```dart typedef EventTransformer<Event> = Stream<Event> Function( Stream<Event> events, EventMapper<Event> mapper, ); ``` - `events` is the stream of `E` events added to the Bloc. - `mapper` turns one event into a stream representing **one run of your handler**; subscribing starts the run, cancelling that subscription cancels the run's Emitter. - What you return decides timing: which events reach `mapper`, and how runs are combined. The default is effectively `events.map(mapper)` flattened concurrently. bloc_concurrency's transformers are one-liners over the same signature, which is why they compose with other stream operators. ## Composing debounce and switchMap ```dart EventTransformer<E> debounceRestartable<E>(Duration duration) { return (events, mapper) => events.debounce(duration).switchMap(mapper); } // in the constructor on<CitySearchQueryChanged>( _onQueryChanged, transformer: debounceRestartable(const Duration(milliseconds: 300)), ); ``` - `debounce` (from stream_transform) forwards an event only once no newer one has arrived for the duration, so a burst of keystrokes collapses into its final query. - `switchMap` subscribes to the newest run and cancels the previous — the same behaviour as bloc_concurrency's `restartable()`. - Equivalent with bloc_concurrency: `(events, mapper) => restartable<E>().call(events.debounce(duration), mapper)`, mirroring the repository's `throttleDroppable` example for infinite lists. ## What cancellation does and does not do - The cancelled run's Emitter is marked done: later `emit` calls are **ignored**, and `emit.isDone` returns `true`. - The Dart code is **not** stopped: `await _cities.search('Li')` still completes and the handler resumes. Check `emit.isDone` before expensive work such as ranking results. - The network request is **not** aborted. If that matters, the handler must pass its own cancellation signal to the HTTP client. ## Alternatives compared | Transformer | Requests while typing | Stale overwrite possible? | |---|---|---| | default (concurrent) | one per keystroke | yes | | debounce + concurrent | one per pause | yes, if a pause splits a word | | `restartable()` only | one per keystroke | no | | debounce + switchMap | one per pause | no | | debounce + `droppable()` | one per pause | no, but keeps the **oldest** query | `droppable()` is the trap here: it ignores the newer query while an older one is running, so the user sees results for 'Li' after typing 'Lisbon'. ## Handler details that still matter - Trim the query and emit `CitySearchInitial()` for an empty string instead of calling the API. - Emit `CitySearchInProgress()` before awaiting so the UI shows progress. - Catch repository errors and emit a failure state; an uncaught error goes to `onError` and is rethrown. - Choose the debounce duration from how people type; the bloc examples use 300 milliseconds.
- Is the emit.isDone check required for correctness after a restartable cancellation?No: a cancelled Emitter already ignores emit, so the stale result cannot reach the state. The check is an optimisation and a clarity aid — it skips work such as sorting or mapping results that nobody will see, and documents that the run may have been superseded.
- Why not debounce in the widget with a Timer instead?It works, but the timing rule then lives in UI code, must be cancelled in dispose, and is invisible to the Bloc's tests. A transformer keeps the behaviour next to the handler it governs and is tested by adding events to the Bloc.
saying these in an interview costs you the question
- Debounce alone guarantees results arrive in typing order
- droppable is the right transformer for search as you type
- switchMap aborts the HTTP request of the older query
- An EventTransformer receives the current state and returns a state
- The handler must cancel the older run itself