flutter
/

GetX Search & Filter: Reactive Search with Debounce

Last Sync: Today

On this page

10
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

GetX Search & Filter: Reactive Search with Debounce

Introduction

Search and filtering are common features in apps that handle large lists. With GetX, you can build a reactive search experience that updates the UI as the user types, with automatic debouncing to avoid excessive updates. This guide covers how to implement search and filter using reactive state, workers, and computed values.

  1. Basic Search with Reactive State

The simplest approach is to store the search query as a reactive variable and compute the filtered list in a getter. The getter accesses the original list and the query; Obx will automatically rebuild whenever either changes.

DARTRead-only
1
class SearchController extends GetxController {
  var allItems = <String>[].obs;
  var query = ''.obs;

  List<String> get filteredItems {
    if (query.value.isEmpty) return allItems;
    return allItems.where((item) => item.toLowerCase().contains(query.value.toLowerCase())).toList();
  }
}

// In UI
TextField(
  onChanged: (value) => controller.query.value = value,
  decoration: InputDecoration(labelText: 'Search'),
),
Obx(() => ListView.builder(
  itemCount: controller.filteredItems.length,
  itemBuilder: (_, i) => ListTile(title: Text(controller.filteredItems[i])),
));

  1. Debouncing Search Input

To avoid rebuilding the list on every keystroke (which could be expensive), use a debounce worker. This will wait until the user stops typing for a short period before updating the query (or before performing an API call).

DARTRead-only
1
class SearchController extends GetxController {
  var allItems = <String>[].obs;
  var query = ''.obs;
  var filteredItems = <String>[].obs;

  @override
  void onInit() {
    super.onInit();
    debounce(query, (_) => _performSearch(), time: Duration(milliseconds: 500));
  }

  void _performSearch() {
    if (query.value.isEmpty) {
      filteredItems.assignAll(allItems);
    } else {
      final filtered = allItems.where((item) => item.toLowerCase().contains(query.value.toLowerCase())).toList();
      filteredItems.assignAll(filtered);
    }
  }
}

  1. Combining Search with Server‑Side Filtering

When the data is large, you may want to perform the search on the server. Use the same debounce pattern to call an API when the user stops typing. Show a loading indicator while the request is in progress.

DARTRead-only
1
class SearchController extends GetxController {
  var query = ''.obs;
  var results = <String>[].obs;
  var isLoading = false.obs;

  @override
  void onInit() {
    super.onInit();
    debounce(query, (_) => search(), time: Duration(milliseconds: 500));
  }

  Future<void> search() async {
    if (query.value.isEmpty) {
      results.clear();
      return;
    }
    isLoading.value = true;
    try {
      final data = await api.search(query.value);
      results.assignAll(data);
    } finally {
      isLoading.value = false;
    }
  }
}

// UI
Obx(() => controller.isLoading.value
    ? CircularProgressIndicator()
    : ListView.builder(...));

  1. Filtering with Multiple Criteria

For more complex filters (e.g., categories, price ranges), add additional reactive variables and compute the combined filtered list.

DARTRead-only
1
class FilterController extends GetxController {
  var allProducts = <Product>[].obs;
  var searchQuery = ''.obs;
  var selectedCategory = ''.obs;
  var minPrice = 0.obs;
  var maxPrice = 1000.obs;

  List<Product> get filteredProducts {
    List<Product> result = allProducts;
    if (searchQuery.value.isNotEmpty) {
      result = result.where((p) => p.name.toLowerCase().contains(searchQuery.value.toLowerCase())).toList();
    }
    if (selectedCategory.value.isNotEmpty) {
      result = result.where((p) => p.category == selectedCategory.value).toList();
    }
    result = result.where((p) => p.price >= minPrice.value && p.price <= maxPrice.value).toList();
    return result;
  }
}

  1. Performance Tips for Large Lists

  • Use debounce – Prevents filtering on every keystroke.
  • Pre‑filter using getters – Works well for client‑side filtering with moderately sized lists.
  • Consider using StateMixin – If you have loading/error states, combine with search.
  • Avoid heavy operations inside getters – If the filtering logic is expensive, use a worker to update a separate reactive list only when needed.
  • Use ListView.builder – Even for filtered lists, always use a builder to avoid rendering all items at once.

Best Practices

  • Keep the original data intact – Always store the unfiltered list separately, and derive the filtered view.
  • Use debounce for async search – Avoid calling the API on every key press.
  • Show search results incrementally – For client‑side filtering, you can update on each keystroke if the list is small, but still use debounce for performance.
  • Provide visual feedback – Show a loading indicator when searching remotely.
  • Clear search easily – Provide a clear button to reset the query.
  • Test with different data sizes – Ensure performance remains smooth.

Common Mistakes

  • ❌ Not using debounce for server‑side search – Causes many API calls. ✅ Use debounce to wait for the user to stop typing.
  • ❌ Storing filtered list separately and forgetting to update it – Leads to inconsistencies. ✅ Use a getter or a worker that updates automatically.
  • ❌ Filtering inside build without Obx – UI won't update. ✅ Wrap the filtered list with Obx or use GetX widget.
  • ❌ Ignoring case sensitivity – Users expect case‑insensitive search. ✅ Use toLowerCase() on both sides.

FAQ

  • Q: Should I use Obx with a getter that filters a large list?
    A: Yes, but be aware that the getter runs on every rebuild. For huge lists, consider using a worker that updates a separate reactive list only when the query changes.
  • Q: How do I reset filters?
    A: Set each filter variable back to its default (e.g., empty string, min=0, max=1000) and the computed list will update automatically.
  • Q: Can I combine local filtering with remote search?
    A: Yes, you can first fetch the list from the server and then filter locally, or always call the server with the search term.
  • Q: How to implement a dropdown filter with GetX?
    A: Use a reactive variable for the selected option and include it in the filtered getter.
  • Q: Does GetX provide any built‑in search widget?
    A: Not directly, but you can easily combine TextField with reactive state.

Conclusion

GetX makes search and filtering simple and reactive. Whether you're filtering a local list or calling a remote API, you can use reactive variables, getters, and debounce workers to build a smooth user experience. These patterns are essential for modern apps that need to handle large datasets.

Try it yourself

import 'package:flutter/material.dart';
import 'package:get/get.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return GetMaterialApp(
      home: SearchDemo(),
    );
  }
}

class SearchController extends GetxController {
  var allItems = <String>[].obs;
  var query = ''.obs;

  @override
  void onInit() {
    super.onInit();
    // Generate some demo data
    allItems.assignAll(List.generate(50, (i) => 'Item ${i + 1}'));
  }

  List<String> get filteredItems {
    if (query.value.isEmpty) return allItems;
    return allItems.where((item) => item.toLowerCase().contains(query.value.toLowerCase())).toList();
  }
}

class SearchDemo extends StatelessWidget {
  final controller = Get.put(SearchController());
  final TextEditingController textController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Search & Filter')),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(16.0),
            child: TextField(
              controller: textController,
              decoration: InputDecoration(
                labelText: 'Search',
                prefixIcon: Icon(Icons.search),
                suffixIcon: IconButton(
                  icon: Icon(Icons.clear),
                  onPressed: () {
                    textController.clear();
                    controller.query.value = '';
                  },
                ),
              ),
              onChanged: (value) => controller.query.value = value,
            ),
          ),
          Expanded(
            child: Obx(() => ListView.builder(
              itemCount: controller.filteredItems.length,
              itemBuilder: (_, index) => ListTile(
                title: Text(controller.filteredItems[index]),
              ),
            )),
          ),
        ],
      ),
    );
  }
}

Test Your Knowledge

Q1
of 3

How can you avoid filtering on every keystroke to improve performance?

A
Use a Timer
B
Use a debounce worker
C
Use a Future.delayed
D
Use a Stream
Q2
of 3

What is the recommended way to store filtered data when the original list is large?

A
Store a separate filtered list and update it manually
B
Use a getter that filters the original list
C
Always filter on the server
D
Use a Set instead of List
Q3
of 3

Which widget should you use to make the UI react to the search query changes?

A
GetBuilder
B
Obx
C
GetX
D
All of the above

Previous

getx pagination

Next

getx rebuild optimization

Related Content

Need help?

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