getx_safe_arch 1.0.1 copy "getx_safe_arch: ^1.0.1" to clipboard
getx_safe_arch: ^1.0.1 copied to clipboard

A production-grade extension layer for GetX that solves common navigation, controller lifecycle, reactive state, and async safety issues in large-scale Flutter applications. Works as a non-invasive wr [...]

getx_safe_arch #

A production-grade extension layer for GetX that solves the most common lifecycle, navigation, reactive state, and async safety issues encountered in large-scale Flutter applications.

Works entirely as a non-invasive wrapper. Never modifies GetX internals.


Why this plugin exists #

GetX is fast and ergonomic. In complex apps, however, a set of recurring bugs surfaces that GetX's default behavior cannot prevent:

Category Symptom
Navigation Same screen reopens without re-fetching data
Navigation Get.arguments is stale when returning to a screen
Controller Get.put controller persists and pollutes the next screen
Controller onInit not called again when navigating back to a route
Reactive Obx doesn't update because .value was never written
Reactive State mutation fires after onClose() — silent corruption
Async API response arrives after controller is disposed
Workers ever() / once() workers not disposed, causing memory leaks
Diagnostics No visibility into which controllers are alive at runtime

This plugin adds a structured, explicit layer over GetX that eliminates each of these failure modes without forking or patching GetX itself.


Installation #

dependencies:
  get: ^4.6.6
  getx_safe_arch: ^0.1.0

Setup #

Add both observers to your GetMaterialApp. This is the only required bootstrap step.

import 'package:getx_safe_arch/getx_safe_arch.dart';

void main() {
  // Optional: enable the debug inspector overlay.
  GetxInspector.enable();
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return GetxInspector.wrap(
      child: GetMaterialApp(
        navigatorObservers: [
          safeArchObserver,         // drives route-scoped controller disposal
          safeArchRouteObserver,    // drives GetxRefreshObserver.onResume
        ],
        initialRoute: Routes.home,
        getPages: AppPages.routes,
      ),
    );
  }
}

Features #

1. Scoped Controller Lifecycle — GetxControllerManager #

Problem: Get.put() registers controllers globally by default. Navigating away and back creates stale instances, or the same controller leaks across unrelated screens.

Solution: Register controllers with an explicit scope. Route-scoped controllers are automatically disposed when their route is popped.

// ❌ Default GetX — global, never auto-disposed
final ctrl = Get.put(ProductController());

// ✅ Route-scoped — disposed when /products is popped from the stack
final ctrl = GetxControllerManager.put(
  ProductController(),
  scope: ControllerScope.route,
);

// ✅ Feature-scoped — survives multiple screens in a checkout flow
final ctrl = GetxControllerManager.put(
  CheckoutController(),
  scope: ControllerScope.feature,
  featureTag: 'checkout',
);

// Dispose the entire checkout feature when flow ends:
GetxControllerManager.disposeFeature('checkout');

// ✅ Global — permanent, app-lifetime controller
final ctrl = GetxControllerManager.put(
  AuthController(),
  scope: ControllerScope.global,
);

Scope reference:

Scope Lifetime Auto-disposal trigger
route Single named route Route popped from navigator stack
feature Named feature group disposeFeature(tag) called explicitly
global App lifetime Never (equivalent to permanent: true)

2. Safe Navigation — GetxNavigator #

Problem: Get.toNamed('/products') when /products is already the current route pushes a duplicate, or does nothing — depending on preventDuplicates. Either way, the controller's onInit does not fire again and data is stale.

Solution: GetxNavigator detects same-route navigation and dispatches a refresh event instead of re-pushing the route.

// ❌ Default GetX — may push duplicate or silently no-op
Get.toNamed(Routes.productDetails, arguments: {'id': id});

// ✅ Safe — dispatches refresh if already on this route
GetxNavigator.toNamed(
  Routes.productDetails,
  arguments: {'id': id},
  refreshStrategy: RefreshStrategy.smart, // default
);

Refresh strategy reference:

Strategy Behavior
RefreshStrategy.never No-ops when target is already the current route
RefreshStrategy.always Always dispatches a refresh event, never re-pushes
RefreshStrategy.smart Dispatches refresh only when arguments differ from last navigation (default)

All standard GetX navigation methods are available:

GetxNavigator.toNamed(routeName, arguments: args);
GetxNavigator.offNamed(routeName);
GetxNavigator.offAllNamed(routeName);
GetxNavigator.to(() => const ProductScreen());
GetxNavigator.off(() => const LoginScreen());
GetxNavigator.back();

3. Screen Refresh on Return — GetxRefreshObserver #

Problem: onInit runs exactly once per controller instance. After navigating to a child route and returning, data is never refreshed.

Solution: Wrap your screen's content with GetxRefreshObserver. It uses RouteAware to detect when the screen regains focus.

class ProductListScreen extends StatelessWidget {
  const ProductListScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final controller = Get.find<ProductListController>();

    return GetxRefreshObserver(
      routeName: Routes.productList,
      onFirstLoad: controller.fetchProducts,   // runs on initial push
      onResume: controller.fetchProducts,       // runs when returning from child
      child: Scaffold(
        body: Obx(
          () => ListView.builder(
            itemCount: controller.products.length,
            itemBuilder: (_, i) => ProductTile(controller.products[i]),
          ),
        ),
      ),
    );
  }
}

The debounceDuration (default: 300ms) prevents duplicate calls from rapid back-navigation or system back gestures.


4. Dispose-Safe Reactive State — RxSafe<T> #

Problem: An async operation completes after onClose() and writes to an Rx variable. GetX does not guard this — the write silently succeeds or triggers a framework error downstream.

Solution: RxSafe<T> wraps Rx<T> and silently no-ops any write after dispose() is called. In debug mode, it emits an assertion error identifying the exact variable and call site.

class OrderController extends GetxController with SafeControllerMixin {
  // ❌ Standard Rx — no post-dispose protection
  // final status = ''.obs;

  // ✅ RxSafe — post-dispose writes are silently ignored (+ debug assertion)
  final status = RxSafe<String>('');
  final total  = RxSafe<double>(0.0);

  Future<void> fetchOrder(String id) async {
    await safeAsync(() async {
      final order = await _repo.getOrder(id);
      // Safe even if onClose() fires during the await:
      safeUpdate(() {
        status.value = order.status;
        total.value  = order.total;
      });
    });
  }

  @override
  void onClose() {
    status.dispose();
    total.dispose();
    super.onClose();
  }
}

// Usage in widgets is identical to standard Obx:
Obx(() => Text(controller.status.value))

Convenience extension:

final count = 0.rxSafe;    // RxSafe<int>
final name  = ''.rxSafe;   // RxSafe<String>

5. Controller Lifecycle Safety — SafeControllerMixin #

Problem: Async methods that run after onClose() are a silent source of state corruption. Workers (ever, once, debounce) attached manually are often not disposed in onClose().

Solution: Mix SafeControllerMixin into any GetxController for:

  • safeAsync() — guards the entire async operation.
  • safeUpdate() — guards a single synchronous state mutation after an await.
  • trackWorker() — auto-disposes workers on onClose().
class CartController extends GetxController with SafeControllerMixin {
  final itemCount = RxSafe<int>(0);

  @override
  void onInit() {
    super.onInit();
    // Worker auto-disposed on onClose() — no manual cleanup needed.
    trackWorker(debounce(itemCount.rx, (_) => _syncToServer()));
  }

  Future<void> addItem(String productId) async {
    await safeAsync(() async {
      final updated = await _repo.addItem(productId);

      // If the controller was closed during the await, this no-ops:
      safeUpdate(() => itemCount.value = updated.itemCount);
    });
  }

  Future<void> _syncToServer() async {
    // Debounced — implementation omitted for brevity.
  }

  @override
  void onClose() {
    itemCount.dispose();
    super.onClose();
  }
}

6. Debug Inspector — GetxInspector #

Problem: There is no runtime visibility into which controllers are alive, what scope they belong to, or what the current route stack looks like.

Solution: GetxInspector.wrap() adds a floating debug overlay. Completely compiled out in release builds.

// main.dart
void main() {
  GetxInspector.enable();
  runApp(const MyApp());
}

// MyApp.build()
return GetxInspector.wrap(
  child: GetMaterialApp(...),
);

The overlay shows:

  • All active controllers, their type name, scope badge, and route/feature tag.
  • The full route stack from oldest to newest.

The overlay is only rendered when kDebugMode == true and enable() has been called. It has zero impact on release builds.


Anti-patterns this plugin prevents #

Anti-pattern 1: Naked Get.put without scope #

// ❌ Controller leaks between navigation sessions
Get.put(ProductController());

// ✅
GetxControllerManager.put(ProductController(), scope: ControllerScope.route);

Anti-pattern 2: Mutating state after dispose #

// ❌ Async gap — controller may be closed by the time this runs
Future<void> load() async {
  final data = await repo.get();
  items.value = data; // crash or silent corruption if closed
}

// ✅
Future<void> load() async {
  await safeAsync(() async {
    final data = await repo.get();
    safeUpdate(() => items.value = data);
  });
}

Anti-pattern 3: Forgetting to dispose workers #

// ❌ Worker lives forever, holds reference to closed controller
@override
void onInit() {
  super.onInit();
  ever(count, (_) => persist());
}

// ✅
@override
void onInit() {
  super.onInit();
  trackWorker(ever(count.rx, (_) => persist()));
}

Anti-pattern 4: No data refresh on screen return #

// ❌ onInit never fires again — screen shows stale data
class ProductListPage extends StatelessWidget { ... }

// ✅
class ProductListPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return GetxRefreshObserver(
      onFirstLoad: controller.fetch,
      onResume: controller.fetch,
      child: ...,
    );
  }
}

Anti-pattern 5: Duplicate controller registration #

// ❌ Throws or returns stale instance if screen is pushed twice
final ctrl = Get.put(ProductController());

// ✅ Returns existing instance if already registered
final ctrl = Get.putIfAbsent(() => ProductController());

Extension methods #

// Null-safe find — returns null instead of throwing
final ctrl = Get.findOrNull<CheckoutController>();
ctrl?.reset();

// Idempotent put — no-op if already registered
final ctrl = Get.putIfAbsent(() => AuthController());

Platform support #

Platform Supported
Android
iOS
Web
macOS
Windows
Linux

Compatibility #

Package Version
Flutter SDK ≥ 3.22.0
Dart SDK ≥ 3.3.0
get ^4.6.6

License #

MIT

0
likes
150
points
22
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A production-grade extension layer for GetX that solves common navigation, controller lifecycle, reactive state, and async safety issues in large-scale Flutter applications. Works as a non-invasive wrapper — never modifies GetX internals.

Repository (GitHub)
View/report issues

Topics

#getx #state-management #navigation #architecture #lifecycle

License

MIT (license)

Dependencies

flutter, get

More

Packages that depend on getx_safe_arch