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
GetX makes reactive state effortless with .obs and Obx(), but developers frequently run into memory leaks and hard-to-debug state contamination. Using permanent Get.put() singletons everywhere retains state long after a screen has closed. Furthermore, reactive workers like ever(), debounce(), and interval() registered in onInit() keep running indefinitely in memory if their Worker subscriptions are not properly tracked and closed.
2. Architectural Principles & Resolution
Responsible GetX architecture relies on three core patterns: 1. Scoped Bindings: Instead of globally calling Get.put() inside UI widgets, register controllers via route Bindings using Get.lazyPut<MyController>(() => MyController()). When the route pops, GetX automatically evicts the controller and frees memory. 2. Worker Teardown: Capture the Worker returned by ever(), debounce(), or interval(): late final Worker _searchWorker = debounce(...);. In onClose(), explicitly call _searchWorker.dispose();. 3. Controller Isolation: Keep business logic, reactive variables (RxInt, RxString), and network clients inside the controller; keep widgets declarative and simple using GetView<MyController>.
3. Complete 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
A reactive search field using GetX debounce workers. When the search route is closed or replaced, the controller onClose hook is triggered, properly disposing workers and evicting state from memory without leftover background listeners.
5. Common Pitfalls & Traps
Pitfall #1: Using Get.put(MyController(), permanent: true) for temporary screens
Pitfall #2: Creating ever() or debounce() workers in build() instead of onInit()
Pitfall #3: Nesting Obx() inside other Obx() widgets without need