← Insights
Architecture

Migrating from BLoC: a Decision Framework

BLoC served Flutter well. If your team is fighting boilerplate, here is a sober framework for whether to migrate, to what, and how to do it screen by screen.

BLoC has been a defensible default in Flutter for years, and for a lot of teams it still is. Events in, states out, one place to point at during a review when someone asks how the screen got into this state. If your team is fluent in it and shipping on schedule, nothing below is an argument to stop. A bloc migration only pays off once the boilerplate has become an actual bottleneck rather than a fashion complaint, and this article is a framework for telling those two situations apart before you commit a team to months of screen-by-screen rewrites.

Disclosure, since it changes how you should read what follows: Utopia builds on a hooks-based stack, and we maintain a skill that automates a large share of the mechanical work in a BLoC-to-hooks migration. That gives us a reason to be precise about when migrating is worth it and when it is not, rather than treating every BLoC codebase as a prospect. The gate below comes first for that reason, and it is meant to send a real share of readers back to their BLoC codebase with nothing more to do than keep shipping.

First: should you migrate at all?

Migrating state management is expensive in engineering time and in the risk of regressions in screens nobody has touched in a year. It is worth doing when the boilerplate is a measured cost rather than an aesthetic complaint. Look for these signals before you plan anything.

Signals that say stay on BLoC:

  • The team is fluent in it. Reviews move fast, features land without anyone relearning the pattern, and a mid-level developer onboards in days.
  • The codebase is uniform. One state-management pattern across the app, not three half-adopted attempts layered from past rewrites.
  • Feature churn is low to moderate. Nobody is writing dozens of new screens a month, so the per-feature ceremony tax rarely dominates the calendar.
  • Juniors onboard fine. New hires read an existing bloc, copy the shape, and ship a correct PR without a senior rewriting their classes.

Signals that say the tax is real:

  • Per-feature ceremony is slowing delivery. A single new field means a new event, a new state, and a mapper function, before any actual logic gets written.
  • Testing has become a chore rather than a safety net. Bloc tests are strong on paper, but the team writes fewer than it should because each one carries the same event/state ceremony as the feature itself.
  • Event and state classes outnumber the logic that uses them. Open a feature folder: if definitions outnumber logic, the ceremony has outgrown the problem it solves.
  • Every screen needs its own mental model. Five developers building five different shapes of Cubit for similar problems is overhead without the payoff.

One or two signals from the second list do not mandate a migration; a team that is otherwise productive can live with some ceremony. What justifies one is a majority of the second list sustained over months, backed by delivery data such as sprint velocity or defect rate on state-layer bugs rather than a single frustrating sprint.

What you are migrating to (and what survives)

A BLoC migration is narrower than it sounds. The state layer changes: Cubits and Blocs are replaced by hook functions, event classes disappear, and emit() becomes direct field mutation. The repository and domain layer does not change at all. Your repositories, API clients, data models, and business rules outside the Cubit stay untouched. A migration that also rewrites the data layer has quietly become a full architecture rewrite, and it should be planned and staffed as one.

Utopia's target pattern is hooks-based, named Screen · State · View: a Screen composes hooks and wires navigation, a hook-based State holds the logic and returns an immutable value, and a View is a plain widget that renders it. We cover the full comparison against BLoC, Riverpod, and Provider in our state management comparison. If you have not decided what to migrate to yet, read that first; this article assumes the decision is made.

Three strategies compared

Once migrating is the right call, the next decision is how much of the app moves at once. There are three viable strategies, and the right one depends more on your release schedule and team size than on any technical property of the codebase.

Three ways to run a BLoC migration. No universal winner; fit depends on team size and release cadence.
StrategyBest forAdvantagesDrawbacksRisk level
Big-bang rewriteSmall apps, a short freeze window, a team fully dedicated to the migrationClean result, no coexistence code, shortest calendar time if it fits in one sprint blockFeature work stops entirely; any missed edge case blocks the whole release, not one screenHigh
Screen-by-screen (strangler)Most production apps with an active roadmap and more than a handful of screensFeature work continues in parallel; each screen is a small, revertable unit; regressions are isolatedLonger calendar time; BLoC and hooks coexist for months, which some developers find confusing at firstLow
New-features-only hybridTeams that cannot justify migrating stable screens at all, only want new work on the new patternZero migration risk on existing screens; new code ships on the pattern you actually want going forwardOld screens never get faster to maintain; the app permanently carries two patternsLow, indefinite

Big-bang rewrite

Rational when the app is small enough that one team can hold the whole thing in their head for a few weeks, and when the business can tolerate a genuine feature freeze. Past roughly a dozen screens with real users depending on them, the blast radius of a single missed edge case grows faster than the time saved by doing it all at once.

Screen-by-screen

The default for most production apps, and the strategy the rest of this article details. BLoC and hooks run side by side for the duration, each screen is migrated, reviewed, and committed independently, and a regression in one migrated screen never touches the thirty that have not been migrated yet.

New-features-only hybrid

Rational when the existing BLoC screens are stable, rarely touched, and not costing anyone real time, but new feature work should not add more BLoC debt to a codebase you are trying to move away from. The tradeoff is honest: you never recover the maintenance cost of the old screens, you only stop it from growing.

The screen-by-screen mechanics

Here is what the mechanics actually look like, based on how our own migration tooling runs it end to end. Start with a leaf screen: something with few or no other screens depending on its state. Before writing any migration code, classify it. A Cubit with ten or fewer public methods, no stream subscriptions, and no StatefulWidget lifecycle counts as simple. More than that, and it needs a decomposition plan before you touch it, splitting the Cubit's responsibilities into separate hooks up front rather than discovering the split is needed halfway through a 400-line hook file.

Then run a pre-flight cleanup sweep, easy to skip and expensive when you do. For every method on the Cubit and every service method it calls, check whether anything outside the Cubit actually calls it. Dead methods should be deleted rather than ported: a faithful 1:1 translation of dead code just rehomes the smell in new syntax and adds review time to code nobody uses. The same applies to fake streams, an async* generator that only iterates an in-memory map or list with no real await in it. That is synchronous iteration wearing a stream's clothes, and porting it forces a stream subscription hook onto data that was never asynchronous to begin with.

With the cleanup done, the actual translation follows a fixed concept map: a Cubit becomes a hook function, emit(newState) becomes direct mutation of a useState value, BlocProvider for local state disappears because the hook is called inside the screen's own state hook, and context.read<XCubit>() for global state becomes useProvided<XState>(). A small, compilable sketch of the shape:

// Before: a Cubit and a BlocBuilder
class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);
  void increment() => emit(state + 1);
}

BlocBuilder<CounterCubit, int>(
  builder: (context, count) => Text('$count'),
);

// After: a hook and a plain widget
class CounterScreenState {
  final int count;
  final void Function() onIncrement;
  const CounterScreenState({required this.count, required this.onIncrement});
}

CounterScreenState useCounterScreenState() {
  // Existing DI stays underneath; useInjected() just reads it.
  final analytics = useInjected<AnalyticsService>();
  final count = useState(0);
  return CounterScreenState(
    count: count.value,
    onIncrement: () {
      analytics.logTap('increment');
      count.value++;
    },
  );
}

class CounterScreenView extends StatelessWidget {
  final CounterScreenState state;
  const CounterScreenView({required this.state});

  @override
  Widget build(BuildContext context) => Text('${state.count}');
}

DI bridging is what makes coexistence possible rather than theoretical. Your existing dependency injection, get_it or otherwise, stays as it is; a single useInjected<T>() hook wraps it, so a hook reads a service the same way a Cubit constructor used to receive one. Global BLoC state moves into a flat _providers map registered once at the app root, while a screen not yet migrated keeps reading its Cubits as before. The rule that keeps this safe is granularity: BLoC and hooks must never mix inside one screen, but they coexist freely across different screens for as long as the migration takes. BLoC packages stay in pubspec.yaml until every screen is done.

The real safety net is what happens around every single change rather than a special pre-migration testing phase. Every migrated screen goes through a structural review before it lands: greps for leftover BlocBuilder, stream subscriptions that should be hook-managed, navigation calls leaking into the state layer, and a State class re-exporting another screen's global state instead of projecting only the fields it needs. A screen that fails review twice gets rolled back and marked blocked, and the migration continues on everything else. If the screen already has automated tests, they have to stay green through the move. If it does not, the fallback is a manual pass through the golden path before committing: render, primary data load, primary actions, navigation round-trip. That pass catches the bug class where every static check passes and the feature still does nothing because a trigger was never wired up.

A screen is not done until dart analyze returns zero errors and the review greps come back clean. The old Cubit and its state files are deleted only in a final cleanup commit once every screen in scope is migrated, so a half-migrated app never carries orphaned Cubit files nobody can tell are load-bearing.

Where AI agents actually fit

The concept-map translation from a Cubit to a hook is exactly the kind of task-scoped work an agent handles well. The mapping is fixed, the anti-patterns are enumerable, and the exit criteria (analyzer clean, greps clean, screen renders) are checkable rather than subjective. That does not make it autonomous work. What makes it usable in practice is the same structure that makes the migration safe for a human team: one screen at a time, a review pass before every commit, a rollback path when a change does not hold up, and a senior engineer deciding whether a blocked screen gets a manual fix or stays parked. Nothing about the mechanics here supports a claim that agents move faster than a careful team. What they do is apply the same fixed translation consistently across dozens of screens without the fatigue of doing the fortieth Cubit-to-hook conversion by hand.

We package exactly this mechanical layer as a skill, utopia-hooks-migrate-bloc: it runs the classification, the cleanup sweep, the translation, and the review-and-block loop described above, per screen, with a senior reviewing every change before it merges. It does not decide whether you should migrate. That decision is the gate at the top of this article, and no tooling changes the answer to it.

Sequencing and the roadmap

A migration that stops the roadmap for a quarter is rarely worth it even when the decision to migrate was correct. Screen-by-screen migration is compatible with a live roadmap specifically because BLoC and hooks coexist at the screen boundary: new feature work can keep shipping on whatever pattern a screen currently uses, and migration happens as its own stream of small, independently revertable commits alongside it.

The only thing worth freezing is a screen actively being migrated: it should not also be the target of new feature work in the same sprint, since both change the same files and reviewing them together defeats the point of small commits. Everything else continues normally: screens not currently being touched, the release cadence, and unrelated feature branches.

Exit criteria per screen are concrete rather than a feeling: the analyzer is clean, the structural greps for leftover BLoC artifacts return nothing, the screen has a proper Screen/State/View split rather than a hook function hiding inline UI, and the old Cubit file is gone. A screen that meets all of that is finished and does not need revisiting until the next unrelated feature touches it. A migration finishes the same way, on the date every screen in scope has independently met that bar rather than a date fixed on a calendar months earlier.

questions

Asked about this

Should I migrate away from BLoC?

Only if the per-feature ceremony is a measured bottleneck: slower delivery, weaker test coverage because tests carry the same ceremony as features, or event and state classes outnumbering the logic that uses them. A team that is fluent in BLoC and shipping on schedule has no obligation to migrate. Check the signals in the gate section above before planning anything.

BLoC to Riverpod or to hooks, which one?

Both are reasonable targets and the choice depends on what you value: Riverpod gives compile-safe dependency injection and a larger ecosystem, hooks-based patterns give testability outside the widget tree and less boilerplate per feature at the cost of a smaller talent pool. See our full state management comparison for the criteria-by-criteria breakdown; this article assumes the target is already chosen and covers how to execute the move.

How long does a BLoC migration take?

It depends on the number of screens, how many of them are complex versus simple (by public-method and stream-subscription count), how much of the app is shared global state versus screen-local state, and how much of the team is dedicated to the migration versus splitting time with feature work. There is no reliable fixed duration figure; estimate from your own screen count and complexity mix rather than a rule of thumb.

Can I migrate incrementally without stopping feature work?

Yes, and for most production apps this is the right approach. BLoC and hooks can coexist across different screens for the full duration of the migration, as long as no single screen mixes both patterns. New feature work continues on unaffected screens while migration proceeds as its own stream of small, independently revertable commits.

Do I need full test coverage before migrating?

It is not a hard requirement, but it changes how much you can trust the result. A screen with existing automated tests can verify the migration kept it working; a screen without them relies on a manual pass through its primary user flows before the change is committed. Neither path is blocked, but the second one asks more of the person doing the migration.

What actually changes in a BLoC migration versus what stays the same?

Only the state layer changes: Cubits and Blocs become hook functions, event classes disappear, and BlocProvider trees become a flat provider map. Your repository layer, API clients, data models, and business rules outside the Cubit are untouched. A project that also rewrites the data layer during a state-management migration has expanded its own scope.

talk to us

Reading about a problem you have?

A reply within 24 hours.

The BLoC migration skill