I gave a talk in 2022 titled "Why GetX is the only state management you need." That talk is still on YouTube. It has a comment from someone who took my advice and built a startup app on GetX. Two years later they sent me a longer message asking how to get out. That message is the reason I am writing this.
I am not going to pretend I never recommended GetX. I did, publicly, with conviction, and for what at the time were good reasons. This post is the honest version of why I changed my mind, where the reasoning was wrong, and the specific bug that made the switch unavoidable.
Context: why GetX seemed appealing
GetX in 2022 looked like a gift. You could write reactive state in three lines. You could navigate without a BuildContext. You could inject dependencies without setting up Provider or get_it. You could show a snackbar from anywhere. For a solo developer or a small team trying to ship fast, the friction reduction was real and the time saved was measurable.
If you have ever set up Provider plus go_router plus get_it for a single screen, you understand the appeal. The pitch was: "all of this in one package, no BuildContext, no boilerplate." For a prototype, it is genuinely faster.
What changed my mind, in order
It was not one thing. It was four, in this order:
- The first circular dependency between two
GetxControllers. - A test suite where
Get.reset()was called inconsistently and a state from one test leaked into the next. - A new hire who could not figure out where a value came from because every controller was registered globally.
- The bug below.
The specific bug
We had a UserController and a SettingsController. The settings screen needed the current user; the user controller needed a setting (preferred theme) when constructing. Both were registered on app start with Get.put.
Get.find<SettingsController>() inside UserController.onInit throws because SettingsController is not registered yet. The fix is to register SettingsController first. The deeper problem: there is no compile-time enforcement of registration order, and the dependency is invisible until the app boots and crashes.
In Riverpod the equivalent would not compile because ref.watch requires the provider to exist in the dependency graph; circular dependencies are detected at runtime with a clear error pointing at both providers. In Bloc, you would inject the dependency in the constructor and the missing parameter would be a compile error.
The honest list of where GetX breaks down at scale
Global registration. Everything is in one Get registry. There is no scoping by feature, by route, by anything other than what you call Get.put and Get.delete on. If two features both register a controller of the same type, the last one wins silently.
Loose lifecycle. Controllers can be permanent: true, fenix: true, or default. Each combination behaves differently when the screen is popped. Documentation is not always clear about the difference between Get.delete and the controller's onClose. Memory leaks accumulate when you mix them.
Mixed concerns. GetX is state management plus routing plus DI plus dialogs plus i18n. When one part has a bug or you need to upgrade, you cannot upgrade independently. A breaking change in the routing layer affects the state layer.
Testing requires Get.reset. Every test that touches Get must clean up. Forget it once and the next test sees stale state. Riverpod's ProviderContainer is created per test and disposed automatically.
Discoverability. Get.find<X>() inside a method body tells you nothing about the dependencies of the class. You have to read the body to find them. Constructor injection makes the dependency graph readable.
Magic with BuildContext. Get.snackbar and Get.dialog work without context because GetX maintains its own navigator key. When you mix this with a non-GetX router (like go_router), the keys diverge and snackbars appear over the wrong overlay.
How I migrated, in order
The migration of the production app took six weeks calendar time, two engineers part-time. The order:
- Replace
Get.snackbarandGet.dialogwithScaffoldMessengerandshowDialog. This was the lowest-risk change and removed a layer of indirection. - Replace
Get.toandGet.offwithgo_router. We did this per route group. - Replace
Get.putandGet.findfor single-purpose dependencies with constructor injection plus aProviderat the screen level. - Replace each
GetxControllerwith a RiverpodNotifier, one feature at a time. - Delete the GetX dependency.
Caption: the order matters because each step removes a dependency without adding a new one mid-flight. Doing controllers first leaves you with two state libraries running simultaneously.
How Riverpod solved the specific bug
The same circular dependency in Riverpod becomes:
If SettingsState accidentally depends on CurrentUser, Riverpod throws at the point of access with a stack trace that names both providers. There is no order-of-registration to get wrong because providers are constructed lazily on first access.
What I would do differently
- I would have stopped recommending GetX publicly the day I felt the first lifecycle confusion. I kept recommending it for six more months because I had built up audience around it.
- I would have warned the people who built on my advice directly. Most of them found out from the next conference talk, not from me.
- I would have written tests with
Get.reset()discipline from the start. Half my pain was test pollution. - I would not have used
Get.snackbarfor anything important. Replacing it later was annoying because we used it everywhere. - I would have separated state from routing from DI from the first prototype. The all-in-one pitch is convenient until you need to swap one piece.
Closing opinion
Do not start a new project on GetX. If you have one already and it works, do not panic — but plan a migration over months, not a rewrite over weekends. The replacement is Riverpod plus go_router plus ScaffoldMessenger plus constructor injection. It is more pieces, and that is a feature, not a bug. For the broader comparison, see Riverpod vs Bloc vs GetX. I used all three in production. For where the resulting structure should land, see Structuring a large Flutter codebase. What actually scales.
Written by the author of Flutterstacks
A developer who shipped production apps in Swift, Kotlin, and Dart — with a genuine native reference point that most Flutter writers simply don't have.
More articles →