flutter
/

Search and Filter with Bloc: Debounced, Reactive List Filtering

Last Sync: Today

On this page

9
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

Search and Filter with Bloc: Debounced, Reactive List Filtering

Search and filter are essential features in many apps. Using Bloc, you can build reactive, performant search experiences that debounce user input, handle loading states, and combine multiple filters elegantly. This guide covers everything from simple local filtering to remote API search with real‑time feedback.

Why Bloc for Search & Filter?

  • Reactive UI – The list updates automatically as the user types or changes filters.
  • Debouncing built‑in – Event transformers let you delay search requests until the user stops typing.
  • Centralized state – Search query, filters, results, and loading state live in one place.
  • Testable – You can unit‑test the search logic and filtering rules without UI.
  • Performance – Optimize rebuilds by listening only to relevant parts of the state.

Local vs Remote Search

ScenarioUse CaseImplementation
Local SearchSmall, static datasetsBloc filters an in‑memory list; no network calls.
Remote SearchLarge datasets or API‑backedBloc sends debounced requests to an API; emits loading/error states.

Example 1: Local Search & Filter

We'll build a product list with a search field and category filter. All data is stored locally, and the bloc filters the list reactively.

DARTRead-only
1
abstract class ProductEvent {}

class SearchQueryChanged extends ProductEvent {
  final String query;
  SearchQueryChanged(this.query);
}

class CategoryFilterChanged extends ProductEvent {
  final String? category;
  CategoryFilterChanged(this.category);
}
DARTRead-only
1
class ProductState extends Equatable {
  final List<Product> allProducts;
  final List<Product> filteredProducts;
  final String searchQuery;
  final String? selectedCategory;
  final Set<String> categories;

  const ProductState({
    required this.allProducts,
    required this.filteredProducts,
    this.searchQuery = '',
    this.selectedCategory,
    required this.categories,
  });

  ProductState copyWith({...}) { ... }

  @override
  List<Object?> get props => [allProducts, filteredProducts, searchQuery, selectedCategory, categories];
}
DARTRead-only
1
class ProductBloc extends Bloc<ProductEvent, ProductState> {
  ProductBloc({required List<Product> initialProducts})
      : super(ProductState(
          allProducts: initialProducts,
          filteredProducts: initialProducts,
          categories: initialProducts.map((p) => p.category).toSet(),
        )) {
    on<SearchQueryChanged>(_onSearchQueryChanged);
    on<CategoryFilterChanged>(_onCategoryFilterChanged);
  }

  void _onSearchQueryChanged(SearchQueryChanged event, Emitter<ProductState> emit) {
    final newState = state.copyWith(searchQuery: event.query);
    final filtered = _applyFilters(newState);
    emit(newState.copyWith(filteredProducts: filtered));
  }

  void _onCategoryFilterChanged(CategoryFilterChanged event, Emitter<ProductState> emit) {
    final newState = state.copyWith(selectedCategory: event.category);
    final filtered = _applyFilters(newState);
    emit(newState.copyWith(filteredProducts: filtered));
  }

  List<Product> _applyFilters(ProductState state) {
    return state.allProducts.where((product) {
      final matchesSearch = state.searchQuery.isEmpty ||
          product.name.toLowerCase().contains(state.searchQuery.toLowerCase()) ||
          product.description.toLowerCase().contains(state.searchQuery.toLowerCase());
      final matchesCategory = state.selectedCategory == null ||
          product.category == state.selectedCategory;
      return matchesSearch && matchesCategory;
    }).toList();
  }
}
DARTRead-only
1
class ProductListPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Products')),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: TextField(
              onChanged: (value) => context.read<ProductBloc>().add(SearchQueryChanged(value)),
              decoration: InputDecoration(
                hintText: 'Search...',
                prefixIcon: Icon(Icons.search),
              ),
            ),
          ),
          BlocBuilder<ProductBloc, ProductState>(
            builder: (context, state) {
              return Wrap(
                children: state.categories.map((cat) {
                  return FilterChip(
                    label: Text(cat),
                    selected: state.selectedCategory == cat,
                    onSelected: (_) => context.read<ProductBloc>().add(CategoryFilterChanged(
                      state.selectedCategory == cat ? null : cat,
                    )),
                  );
                }).toList(),
              );
            },
          ),
          Expanded(
            child: BlocBuilder<ProductBloc, ProductState>(
              builder: (context, state) {
                if (state.filteredProducts.isEmpty) {
                  return Center(child: Text('No products found'));
                }
                return ListView.builder(
                  itemCount: state.filteredProducts.length,
                  itemBuilder: (_, index) => ListTile(
                    title: Text(state.filteredProducts[index].name),
                    subtitle: Text(state.filteredProducts[index].category),
                  ),
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

Example 2: Remote Search with Debouncing

When searching against an API, you want to avoid sending a request on every keystroke. Use an event transformer with debounce.

YAMLRead-only
1
dependencies:
  bloc_concurrency: ^0.2.0
  stream_transform: ^2.1.0
DARTRead-only
1
abstract class SearchEvent {}
class SearchQueryChanged extends SearchEvent {
  final String query;
  SearchQueryChanged(this.query);
}

abstract class SearchState {}
class SearchInitial extends SearchState {}
class SearchLoading extends SearchState {}
class SearchSuccess extends SearchState {
  final List<Item> items;
  SearchSuccess(this.items);
}
class SearchFailure extends SearchState {
  final String message;
  SearchFailure(this.message);
}

import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:stream_transform/stream_transform.dart';

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

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

  SearchBloc(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 items = await repository.search(event.query);
      emit(SearchSuccess(items));
    } catch (e) {
      emit(SearchFailure(e.toString()));
    }
  }
}
DARTRead-only
1
BlocConsumer<SearchBloc, SearchState>(
  listener: (context, state) {
    if (state is SearchFailure) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(state.message)),
      );
    }
  },
  builder: (context, state) {
    if (state is SearchLoading) {
      return Center(child: CircularProgressIndicator());
    }
    if (state is SearchSuccess) {
      return ListView.builder(
        itemCount: state.items.length,
        itemBuilder: (_, index) => ListTile(title: Text(state.items[index].name)),
      );
    }
    return Center(child: Text('Start typing to search...'));
  },
)

Combining Search, Filters, and Pagination

For complex lists, you may need to combine search, multiple filters, and pagination. Keep the state clean and use copyWith to update only changed fields.

DARTRead-only
1
class SearchFilterState extends Equatable {
  final String query;
  final Set<String> categories;
  final double minPrice;
  final double maxPrice;
  final List<Item> items;
  final bool hasReachedMax;
  final int currentPage;
  final bool isLoading;

  // ... copyWith, props
}

// In bloc:
on<LoadMore>((event, emit) async {
  if (state.hasReachedMax) return;
  emit(state.copyWith(isLoading: true));
  final newItems = await repository.search(
    query: state.query,
    page: state.currentPage + 1,
    filters: ...
  );
  emit(state.copyWith(
    items: [...state.items, ...newItems],
    currentPage: state.currentPage + 1,
    hasReachedMax: newItems.isEmpty,
    isLoading: false,
  ));
});

Performance Optimizations

  • Use context.select or BlocSelector – Rebuild only the parts of the UI that depend on specific state fields (e.g., only the list when results change, not the search field).
  • Debounce input – Avoids flooding the API or filtering on every character.
  • Cancel previous requests – With switchMap (as in debounce example), if a new event arrives while a previous request is in flight, the old one is cancelled.
  • Use Equatable for states – Prevents unnecessary rebuilds when state objects are equal.
  • Lazy load lists – Implement pagination to load only what the user sees.

Best Practices

  • Keep search/filter logic in the bloc – Not scattered in UI widgets.
  • Use separate events for each filter change – Makes it easy to track and test.
  • Reset pagination when search query or filters change – Emit a fresh state with cleared items.
  • Provide visual feedback – Show loading indicators and empty states.
  • Throttle or debounce API calls – Use event transformers with debounce or throttle.
  • Cancel ongoing requests – When a new search starts, cancel the previous one to avoid race conditions.

Common Mistakes

  • ❌ Calling repository directly in UI – Bypasses bloc, makes testing difficult.
  • ❌ No debouncing – Causes too many API calls and poor user experience.
  • ❌ Not handling empty state – The list may disappear completely; show a message.
  • ❌ Mutating the list instead of emitting a new state – The UI won't update.
  • ❌ Rebuilding the entire screen on every keystroke – Use BlocSelector to isolate the list from the search input.

What's Next?

Now that you can build reactive search and filter, explore more advanced patterns like infinite scroll pagination and combining with form validation.

Next, explore Form validation with Bloc and Bloc testing.

Test Your Knowledge

Q1
of 3

What is the primary reason to debounce search input?

A
To improve UI responsiveness
B
To reduce the number of API calls
C
To simplify the code
D
To increase performance on the client
Q2
of 3

Which event transformer is used to cancel previous requests when a new event arrives?

A
sequential
B
concurrent
C
switchMap
D
droppable
Q3
of 3

In a local search/filter scenario, where should the filtering logic be placed?

A
Inside the UI widget
B
In the bloc using pure functions
C
In the repository
D
In a separate service class

Frequently Asked Questions

How do I implement debounce without bloc_concurrency?

You can manually use Timer in your event handler, but it's more error‑prone. The recommended approach is to use the debounce transformer provided by bloc_concurrency and stream_transform as shown in the remote search example.

Should I use Bloc or Cubit for search?

Both work. If you only need simple local filtering, Cubit is enough. If you need complex async operations, debouncing, or multiple filters, Bloc with events gives you better traceability and control.

How do I combine search with pagination?

Maintain a state that holds the current query, filters, page number, and items. When the search query changes, reset to page 1. When the user scrolls to the bottom, emit a LoadMore event that appends new items.

What if my search results come from both local and remote sources?

You can implement a repository that decides which source to use. The bloc only sees the repository interface, so the source is abstracted away.

Previous

bloc pagination

Next

bloc upload download

Related Content

Need help?

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