GetX got me shipping fast, but the magic that felt great on day one started biting me as the app grew. Here's the honest story of why I switched to Riverpod — and what actually got better.
Let me be honest: GetX and I had a good thing going for a while. You drop in one package and suddenly you've got routing, dependency injection, and reactive state all in one place. My first couple of production apps were live before I'd even finished reading the docs. For getting an idea out the door, it's genuinely hard to beat.
But here's the thing nobody tells you — the same magic that makes GetX feel amazing on day one is exactly what starts to hurt around month three. This isn't a hit piece. It's just me telling you what broke for me, and why I haven't looked back since switching to Riverpod.
Where it started to fall apart
My biggest headache was global state that lived everywhere and nowhere. Anything I registered with Get.put was reachable from any corner of the app, which sounds convenient until you're three screens deep trying to figure out who's mutating a controller and when it actually gets disposed. Spoiler: I never really knew.
Here's the kind of thing I mean — looks harmless, but nobody actually owns this controller:
class CartController extends GetxController {
final items = <Item>[].obs;
void add(Item item) => items.add(item);
}
// Registered once, somewhere during startup...
Get.put(CartController());
// ...and grabbed from anywhere, any time.
Get.find<CartController>().add(item);
// Who owns it? When is it disposed? Good luck.Then there's the reactivity. Forget a single .obs or skip wrapping something in Obx, and the UI just... quietly stops updating. No error, no warning, nothing. You sit there refreshing, convinced your logic is broken, when really you missed one tiny piece of ceremony:
// Updates fine — wrapped in Obx.
Obx(() => Text('${controller.items.length}'));
// Compiles, runs, looks correct... and never rebuilds.
Text('${controller.items.length}');What made me fall for Riverpod
Riverpod flips the whole thing around: your dependencies are explicit and the compiler actually has your back. You declare a provider once, read it where you need it, and if you wire something up wrong the analyzer yells at you before the app ever runs. After a year of silent GetX bugs, that felt like a luxury.
Here's the same cart, the Riverpod way — the dependency is right there in the widget, no hidden global lookup:
final cartProvider =
NotifierProvider<CartNotifier, List<Item>>(CartNotifier.new);
class CartNotifier extends Notifier<List<Item>> {
@override
List<Item> build() => [];
void add(Item item) => state = [...state, item];
}
// In the widget — explicit, and the analyzer checks it.
final items = ref.watch(cartProvider);And testing — oh man, testing finally became something I didn't dread. Override a provider, pump the widget, check the result. Done. No global singletons to reset, no leftover state bleeding from one test into the next:
test('adds an item to the cart', () {
final container = ProviderContainer();
addTearDown(container.dispose);
container.read(cartProvider.notifier).add(item);
expect(container.read(cartProvider), hasLength(1));
});So should you actually switch?
Real talk: if you've got a small app and GetX is humming along, don't rip it out just because some guy on the internet (me) told you to. Working software beats trendy software every time.
But if you can feel your state layer fighting you as the app grows — if you're losing afternoons to 'why isn't this rebuilding' — give Riverpod a weekend. For me the explicitness paid for itself almost immediately, and I'd make the same call again without thinking twice.
