Open the GetX issue tracker in 2026 and you’ll see the problem. The latest stable release on pub.dev is still 4.7.3, version 5.0 has been sitting in release candidate for a long time, and the newest threads are titled things like “When will version 5.0 be officially released?” next to announcements of community forks that want to carry the project on. That’s a lot of uncertainty for the one package doing your routing, dependency injection and state. This post covers how we approach a GetX to BLoC migration on a production app without stopping feature work for a quarter.

We did this for a consumer fintech and commerce client: the core app architecture moved from GetX to BLoC while the app stayed in the stores and kept shipping. The approach below is what we’d do again.

Why a GetX to BLoC migration is three migrations

GetX bundles three jobs, and teams that treat it as one big swap end up with a rewrite branch that never merges. Split it first.

  • State: GetxController, .obs fields and Obx widgets. This becomes Cubit or Bloc plus BlocBuilder.
  • Dependency injection: Get.put, Get.lazyPut, Get.find. This becomes RepositoryProvider and BlocProvider, or a service locator like get_it if you already like that style.
  • Navigation and overlays: Get.to, Get.back, Get.snackbar, Get.dialog. These are context-free calls, and they’re scattered everywhere. This becomes Navigator 2.0 or go_router, plus a global ScaffoldMessenger key for snackbars.

Order matters. Do state first, feature by feature. Leave navigation for last, because GetMaterialApp wraps a normal MaterialApp and BLoC providers work fine underneath it. You can run both systems side by side for months. We did.

The strangler pattern, one feature at a time

Pick the feature with the most bugs or the most upcoming work. That’s where the migration pays for itself first. For each feature:

  1. Pull the business logic out of the controller. Most GetX controllers mix API calls, caching and UI state. Move the data access into a plain Dart repository class with no GetX import. This step alone is worth doing even if you stopped here.
  2. Write the Cubit against that repository. Start with Cubit, not Bloc. A GetxController with methods maps almost one to one onto a Cubit with methods. Reach for full Bloc with events only where you need event transformers (search-as-you-type, payment retries).
  3. Swap the widgets. Obx(() => ...) becomes BlocBuilder. Fine-grained .obs fields usually collapse into one immutable state class, and that’s where a lot of the old “why did this rebuild” bugs disappear.
  4. Delete the controller. Not deprecate. Delete. Half-migrated features with both a controller and a Cubit are worse than either.
class CartCubit extends Cubit<CartState> {
  CartCubit(this._repo) : super(const CartState());
  final CartRepository _repo;

  Future<void> load() async {
    emit(state.copyWith(loading: true));
    try {
      final items = await _repo.fetch();
      emit(state.copyWith(loading: false, items: items));
    } catch (e) {
      emit(state.copyWith(loading: false, error: e.toString()));
    }
  }
}

Reactive workers need a little thought. GetX’s ever, debounce and interval don’t have a single drop-in replacement. In Bloc, you express them as event transformers: the bloc_concurrency package gives you sequential, concurrent, droppable and restartable, and a debounce is a few lines with stream_transform. For a payment screen, droppable on the “Pay” event is the one you want. A double tap should never start two payment attempts.

The cross-feature bridge

During the transition, an old GetX screen will need data that a new Cubit now owns. Don’t duplicate the state. Register the Cubit so legacy code can read it, or have the legacy controller subscribe to the Cubit’s stream and mirror it into an .obs field. It’s ugly on purpose. Every bridge is a reminder of what’s left, and you delete it when the last consumer moves.

Keeping the team shipping during the migration

The rule that made this work for us: no feature freeze, but no new GetX either. New screens are written in BLoC from day one. Old screens migrate when someone is already in there for a feature or a fix, plus a small fixed slice of each sprint for the screens nobody touches.

To keep that rule honest, put a ratchet in CI. Count the files that import GetX, store the number in the repo, and fail the build if it ever goes up.

count=$(grep -rl "package:get/" lib | wc -l | tr -d ' ')
budget=$(cat .getx-budget)
if [ "$count" -gt "$budget" ]; then
  echo "GetX imports went up: $count > $budget"; exit 1
fi

Whoever migrates a feature lowers the number in .getx-budget in the same pull request. It’s crude, and it works. The number on the dashboard goes down every week and nobody has to argue about it in review.

Tests come along for free. A Cubit with an injected repository is trivial to test with the bloc_test package: given this repository response, expect these states in this order. GetX controllers with Get.find calls inside them are much harder to isolate, so most teams we’ve seen have very few tests at that layer. The migration is a good moment to add them, one feature at a time, because you’re touching the logic anyway.

The last mile: navigation

When the state layer is done, what’s left is mostly Get.to, Get.back and overlay calls. Replace snackbars and dialogs first (a global ScaffoldMessenger key and a navigator key cover nearly everything), then move routes to go_router or plain Navigator. Deep links are where you’ll find surprises, so test every one on both platforms before you drop GetMaterialApp.

Is BLoC the only answer? No. Riverpod is a perfectly good destination, and the same strangler steps apply. We chose BLoC for this client because the explicit event and state types made payment flows easy to audit, and that mattered more than having less boilerplate. The same thinking applies outside Flutter too: a React Native team moving off an all-in-one state library would follow the same order of state, then DI, then navigation.

Where to start

If you’re sitting on a large GetX codebase, start by counting. How many controllers, how many Get.find calls, how many context-free navigation calls. That gives you a real estimate instead of a guess, and it usually shows that the scary part (navigation) is smaller than the boring part (state), which is good news, because the boring part can be done gradually.

At orithLabs, the two of us who scope this kind of work also write the code. If you’d like a second pair of eyes on a migration plan, or help carrying one out, have a look at our mobile development work and the fintech and commerce case where we did this migration.