Skip to content
AB

State Management in Flutter: Choosing the Right Level of Structure

Learn when setState is enough, when state should move out of a widget, and how Provider, Riverpod, and BLoC differ in real Flutter projects.

Ashish BhakhandPublished Updated

Flutter state management becomes confusing when the conversation starts with package names.

The better starting point is ownership: what can change, who is allowed to change it, and which parts of the UI need to know? Once those answers are clear, the mechanism is usually straightforward.

This guide begins with setState, identifies the point where local state becomes application state, and compares Provider, Riverpod, and BLoC without pretending one tool is universally best.

State is data that changes over time#

In a Flutter app, state includes more than server responses:

  • the selected tab;
  • text entered into a form;
  • whether a request is in progress;
  • authenticated user information;
  • cached products;
  • validation failures and retry state.

Some state belongs to one widget and disappears when that widget leaves the tree. Other state is shared across routes, survives navigation, coordinates asynchronous work, or represents a business workflow.

That distinction matters more than the library.

Start with local state#

setState is Flutter's built-in way to tell a StatefulWidget that its local data changed and it should build again.

Code
class QuantityPicker extends StatefulWidget {
  const QuantityPicker({super.key});

  @override
  State<QuantityPicker> createState() => _QuantityPickerState();
}

class _QuantityPickerState extends State<QuantityPicker> {
  int _quantity = 1;

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        IconButton(
          onPressed: _quantity == 1
              ? null
              : () => setState(() => _quantity--),
          icon: const Icon(Icons.remove),
        ),
        Text('$_quantity'),
        IconButton(
          onPressed: () => setState(() => _quantity++),
          icon: const Icon(Icons.add),
        ),
      ],
    );
  }
}

This is not a primitive solution waiting to be replaced. The state is small, synchronous, and owned by one widget. Moving it into a global container would make the feature harder to follow.

Use setState when:

  • one widget or a small subtree owns the value;
  • the state has the same lifetime as that widget;
  • transitions are simple;
  • the logic does not need an isolated unit test.

Recognize when state has outgrown the widget#

A product screen might begin by fetching data directly:

Code
class ProductListScreen extends StatefulWidget {
  const ProductListScreen({super.key});

  @override
  State<ProductListScreen> createState() => _ProductListScreenState();
}

class _ProductListScreenState extends State<ProductListScreen> {
  List<Product> _products = const [];
  bool _loading = true;
  Object? _error;

  // Fetching and rendering logic now share this class.
}

The problem is not that setState rebuilds widgets. Rebuilding is how Flutter works. The pressure comes from mixed responsibilities and increasingly complex transitions.

Move state into a dedicated owner when:

  • multiple widgets need the same source of truth;
  • asynchronous work needs loading, success, empty, and error states;
  • business rules are mixed into event handlers;
  • state must survive a widget's lifecycle;
  • transitions deserve focused tests;
  • dependencies such as repositories need clear ownership and disposal.

Model states, not combinations of flags#

Several booleans can describe impossible situations: loading and failed, or empty and loaded at the same time. A sealed hierarchy makes valid states explicit.

Code
sealed class ProductState {
  const ProductState();
}

class ProductLoading extends ProductState {
  const ProductLoading();
}

class ProductReady extends ProductState {
  const ProductReady(this.products);

  final List<Product> products;
}

class ProductFailure extends ProductState {
  const ProductFailure(this.message);

  final String message;
}

The UI can handle the states exhaustively:

Code
Widget buildProducts(ProductState state) {
  return switch (state) {
    ProductLoading() => const CircularProgressIndicator(),
    ProductReady(:final products) when products.isEmpty =>
      const Text('No products found'),
    ProductReady(:final products) => ProductList(products: products),
    ProductFailure(:final message) => Text(message),
  };
}

This improvement is independent of Provider, Riverpod, or BLoC. Good state modeling travels between tools.

Separate the workflow from rendering#

A small view model can own the asynchronous transition:

Code
class ProductViewModel extends ChangeNotifier {
  ProductViewModel(this._repository);

  final ProductRepository _repository;

  ProductState state = const ProductLoading();

  Future<void> load() async {
    state = const ProductLoading();
    notifyListeners();

    try {
      final products = await _repository.fetchProducts();
      state = ProductReady(products);
    } catch (error) {
      state = const ProductFailure('Could not load products');
    }

    notifyListeners();
  }
}

The state owner does not render widgets. The widget does not fetch or parse data. This separation is where most of the maintainability benefit comes from.

Provider: simple access to tree-scoped objects#

Provider builds on Flutter's inherited-widget mechanism and is well suited to exposing objects down a subtree. A common pattern is a ChangeNotifier view model plus ChangeNotifierProvider.

Provider is a good fit when:

  • the team understands Flutter's widget lifecycle;
  • state naturally belongs to a subtree;
  • ChangeNotifier or immutable value objects are sufficient;
  • you want a relatively small conceptual jump from Flutter's built-in APIs.

Its main risk is allowing a notifier to grow into a feature-wide “god object.” Keep state owners focused, expose intentional operations, and avoid putting unrelated state into one provider merely because it is convenient to access.

Riverpod: explicit dependencies and flexible lifecycles#

Riverpod moves provider definitions outside BuildContext and makes dependencies between providers explicit. It offers families, automatic disposal, overrides for tests, and APIs for synchronous and asynchronous state.

Riverpod is a good fit when:

  • features have several stateful dependencies;
  • asynchronous state and caching are common;
  • test overrides are valuable;
  • state lifecycles do not map neatly to one widget subtree;
  • the team wants compile-time-friendly dependency composition.

Riverpod gives a team many ways to solve a problem. Establish conventions for provider scope, naming, generated code, and where business rules live so that flexibility does not become inconsistency.

BLoC and Cubit: explicit transitions and team conventions#

BLoC organizes state around inputs and outputs. Full BLoC uses events that produce states; Cubit exposes methods that emit states with less ceremony.

Code
class ProductCubit extends Cubit<ProductState> {
  ProductCubit(this._repository) : super(const ProductLoading());

  final ProductRepository _repository;

  Future<void> load() async {
    emit(const ProductLoading());

    try {
      emit(ProductReady(await _repository.fetchProducts()));
    } catch (error) {
      emit(const ProductFailure('Could not load products'));
    }
  }
}

BLoC or Cubit is a good fit when:

  • explicit, observable transitions matter;
  • workflows have many events and states;
  • a larger team benefits from one strong convention;
  • event transformations, logging, or replayable debugging are useful;
  • boilerplate is an acceptable trade for predictability.

Use Cubit when methods describe the interactions clearly. Introduce events when event identity, transformation, or decoupling provides real value.

Compare the decision, not the popularity#

ApproachBest starting pointMain strengthWatch for
setStateLocal, short-lived UI stateMinimal indirectionMixing data access and business workflows into widgets
ProviderTree-scoped objects and modest shared stateFamiliar Flutter lifecycle and simple compositionLarge mutable notifiers and broad rebuilds
RiverpodDependency-rich and asynchronous featuresExplicit composition, disposal, overrides, caching patternsToo many provider styles without team conventions
BLoC/CubitComplex workflows or convention-heavy teamsPredictable transitions and strong separationCeremony for simple interactions

Flutter's own state-management overview lists multiple valid approaches because application constraints differ. Package choice should follow the problem and the team, not a universal ranking.

Rebuilds are not automatically a performance problem#

Flutter is designed to rebuild widgets. Optimize when measurement shows expensive work.

Useful habits include:

  • keep expensive computation outside build;
  • watch the smallest state slice a widget needs;
  • split large widgets at meaningful responsibility boundaries;
  • use const where values are actually constant;
  • profile with DevTools before introducing caching or selectors everywhere.

A state-management package cannot compensate for slow work performed during every build, and a rebuild counter alone does not prove a user-visible problem.

Test behavior through the state owner#

When the workflow lives outside the widget, a test can verify transitions without pumping a screen:

Code
test('load publishes products returned by the repository', () async {
  final repository = FakeProductRepository(
    products: const [Product(id: '1', name: 'Keyboard')],
  );
  final viewModel = ProductViewModel(repository);

  await viewModel.load();

  expect(viewModel.state, isA<ProductReady>());
});

Then use widget tests for presentation: loading indicators, empty messages, errors, retry actions, and product rows.

A practical selection process#

Before adopting a package, answer these questions:

  1. Is the state local, shared, or application-wide?
  2. Who owns its lifecycle?
  3. Are transitions simple methods or meaningful events?
  4. Does the state coordinate multiple repositories or workflows?
  5. What conventions does the team already understand?
  6. How will the behavior be tested and debugged?

Try the smallest approach that answers those questions cleanly. Build one representative feature before setting a project-wide standard.

Structure should grow with responsibility#

Use setState confidently for local UI state. Extract a state owner when workflows, sharing, or testing demand it. Choose Provider for straightforward tree-scoped composition, Riverpod for richer dependency and lifecycle needs, and BLoC or Cubit when explicit transitions and strong conventions help the team.

The goal is not to eliminate rebuilds or adopt the most powerful library. It is to make state ownership obvious and changes predictable.

← Back to all writing