getx_safe_arch 1.0.1
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 anawait.trackWorker()— auto-disposes workers ononClose().
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