Skip to main content
Back to all tutorials
Tested: Flutter 3.24.3 • Dart 3.5.3
T04.05 Verified September 2026
GetX Bindings Workers Lifecycle Rx State Management

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 Tested Flutter Code

main.dart (Flutter 3.x+ ready)
lib/main.dart Copy & Paste into a new Flutter project
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

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.

Official Flutter References & Standards