Use GetX Controllers, Bindings and Workers Responsibly
Avoid memory leaks and hidden global state in GetX apps using scoped Bindings, onInit/onClose lifecycle hooks, and worker disposal.
1. The Core Challenge & Problem
2. Architectural Principles & Resolution
3. Complete Tested Flutter Code
main.dart (Flutter 3.x+ ready)import 'package:flutter/material.dart';
import 'package:get/get.dart';
void main() => runApp(const GetMaterialApp(home: ProductSearchPage()));
// 1. Controller with explicit lifecycle hooks and worker cleanup
class ProductSearchController extends GetxController {
final query = ''.obs;
final results = <String>[].obs;
final isLoading = false.obs;
Worker? _debounceWorker;
@override
void onInit() {
super.onInit();
// Debounce query changes by 300ms before executing search
_debounceWorker = debounce<String>(
query,
(val) => _performSearch(val),
time: const Duration(milliseconds: 300),
);
}
void _performSearch(String term) async {
if (term.isEmpty) {
results.clear();
return;
}
isLoading.value = true;
await Future.delayed(const Duration(milliseconds: 300));
results.assignAll(['Product 1 for "$term"', 'Product 2 for "$term"']);
isLoading.value = false;
}
@override
void onClose() {
// CRITICAL: Clean up reactive workers to prevent memory leaks!
_debounceWorker?.dispose();
super.onClose();
}
}
// 2. Scoped Binding for clean memory management on route pop
class ProductSearchBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<ProductSearchController>(() => ProductSearchController());
}
}
// 3. Declarative UI using GetView
class ProductSearchPage extends GetView<ProductSearchController> {
const ProductSearchPage({super.key});
@override
Widget build(BuildContext context) {
// Inject controller for this route scope
Get.put(ProductSearchController());
return Scaffold(
appBar: AppBar(title: const Text('T04.05: GetX Lifecycle')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
onChanged: (val) => controller.query.value = val,
decoration: const InputDecoration(labelText: 'Search with GetX Debounce', border: OutlineInputBorder()),
),
const SizedBox(height: 16),
Obx(() {
if (controller.isLoading.value) return const LinearProgressIndicator();
return Text('Query: "${controller.query.value}" (${controller.results.length} items)');
}),
const Divider(),
Expanded(
child: Obx(() => ListView.builder(
itemCount: controller.results.length,
itemBuilder: (context, i) => ListTile(title: Text(controller.results[i])),
)),
),
],
),
),
);
}
} 4. Expected Visual & Behavioral Result
5. Common Pitfalls & Traps
Pitfall #1: Using Get.put(MyController(), permanent: true) for temporary screens
Remedy: Only mark controllers permanent for true app-wide singletons (like AuthService or ThemeService). Use route Bindings or lazyPut for feature controllers.
Pitfall #2: Creating ever() or debounce() workers in build() instead of onInit()
Remedy: Registering workers in build() creates a brand-new duplicate worker on every single build pass. Always register workers once in onInit() and dispose them in onClose().
Pitfall #3: Nesting Obx() inside other Obx() widgets without need
Remedy: Keep Obx widgets scoped to the smallest possible subtrees containing the actual reactive values to minimize rebuild scopes.