Home Services Work About Blog Contact Let's Talk
Home/ Blog/ Flutter State Management
Flutter Apps

Flutter State Management for Enterprise Apps: Riverpod vs BLoC vs Provider

Why State Management Is the #1 Flutter Architecture Problem

Of all the architectural decisions you make when building enterprise Flutter apps, state management has the highest long-term impact on maintainability, testability, and team velocity. Get it wrong and you'll spend months untangling stateful widgets that do too much, dealing with race conditions between async operations, and writing tests that are brittle because state is buried inside widget trees. Get it right and your team can add features confidently, your test suite runs reliably, and onboarding new developers takes days rather than weeks.

Flutter 3.38 reignited the state management debate in late 2025. With the framework maturing and enterprise adoption accelerating — we're building Flutter apps that integrate with Microsoft 365 APIs, SharePoint REST endpoints, and MSAL authentication — the community needed clear guidance. The three dominant options are Provider (the original recommended approach), Riverpod 2.x (Provider's spiritual successor from the same author), and BLoC (Business Logic Component, the most structured pattern). Each has legitimate strengths; the wrong choice isn't which one you pick but whether you understand the trade-offs before committing your team to an architecture.

In our Flutter Microsoft 365 work, we've shipped production apps using all three patterns. Our recommendation has evolved over time. For apps built or rebuilt from mid-2024 onward, Riverpod 2.x is our default choice because its compile-time safety, code generation support, and testing ergonomics are significantly better than Provider. BLoC remains our choice for teams with Java or Angular backgrounds who are comfortable with streams and prefer strict separation. Provider is appropriate only for greenfield projects with very limited scope or for maintaining existing codebases where migration costs outweigh benefits.

Provider: Simple, Effective, and Still Valid for Small Apps

Provider is Flutter's original community-endorsed state management solution and remains the most widely used pattern simply because of its age and the sheer volume of existing code written against it. Its model is simple: wrap parts of your widget tree in ChangeNotifierProvider, define a ChangeNotifier subclass that holds your state and calls notifyListeners() when it changes, and consume the state with Consumer or context.watch(). For apps with limited state complexity, this works well and has minimal boilerplate.

The limitations of Provider become apparent in enterprise contexts. Because Provider scopes state to widget tree position, you can run into situations where state needs to be accessible in parts of the tree that don't share a common ancestor — requiring you to place providers higher in the tree than makes architectural sense. Provider also has no built-in handling for async state — loading, data, and error are all states you manage manually in your ChangeNotifier. And testing requires building widget trees even for logic tests, which slows down test suites significantly.

If you're maintaining an existing Provider-based Flutter app that integrates with SharePoint or Microsoft Graph API, our advice is not to migrate immediately. Provider works, and a working app is more valuable than a perfectly architected one. However, any new feature development should use Riverpod, and you should set a roadmap to incrementally migrate existing Providers over the next 12–18 months. The Riverpod migration guide supports gradual adoption precisely because the author understood that a hard cutover would be impractical for teams with significant Provider investments.

Riverpod 2.x: The Modern Replacement for Provider

Riverpod (an anagram of Provider) was created by Remi Rousselet, the same author as Provider, explicitly to address Provider's limitations. The key conceptual shift in Riverpod is that providers are not tied to the widget tree — they are global constants defined at the top level of your Dart files. This solves the scoping problem entirely. Any provider can be accessed from anywhere, and the framework manages initialization and disposal based on listener count, not widget position.

Riverpod 2.x added code generation via @riverpod annotations and introduced the Notifier and AsyncNotifier base classes. Code generation eliminates a category of boilerplate errors that plagued earlier Riverpod versions — you annotate a class with @riverpod, run dart run build_runner watch, and the provider definition is generated automatically. This also gives you compile-time checks: if you reference a provider that doesn't exist or pass the wrong type, you get a compile error rather than a runtime crash.

The AsyncNotifier class is particularly valuable for enterprise Flutter apps that make frequent HTTP calls to SharePoint REST API endpoints or Microsoft Graph. It builds in loading, data, and error state management with the AsyncValue<T> type, which is a sum type that can be in exactly one of three states at any moment. Widgets consume AsyncValue using pattern matching or the when() method, producing clean UI code that explicitly handles every possible state. We've used this pattern extensively in our SharePoint mobile app work and it consistently produces more resilient apps than the manual loading flag approach.

Riverpod in Practice: AsyncNotifier, FutureProvider, and StateProvider

Understanding when to use each Riverpod primitive is key to writing idiomatic Riverpod 2.x code. FutureProvider is for simple read-only async data that doesn't change — a user's profile fetched from Microsoft Graph, tenant configuration loaded at startup. StateProvider is for simple synchronous state that a widget can read and write — a selected tab index, a filter value, a toggle. AsyncNotifier is for complex async state that needs methods — a SharePoint list that can be loaded, refreshed, and paginated. Notifier is for complex synchronous state with methods — a form state object, a selection state with multiple operations.

Dart — Riverpod AsyncNotifier for SharePoint List Fetch
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:dio/dio.dart';

part 'tasks_provider.g.dart';

// Model
class SharePointTask {
  final int id;
  final String title;
  final String status;
  final DateTime dueDate;

  const SharePointTask({
    required this.id,
    required this.title,
    required this.status,
    required this.dueDate,
  });

  factory SharePointTask.fromJson(Map<String, dynamic> json) => SharePointTask(
    id: json['Id'] as int,
    title: json['Title'] as String,
    status: json['TaskStatus'] ?? 'Not Started',
    dueDate: DateTime.parse(json['DueDate']),
  );
}

// Repository
class TasksRepository {
  final Dio _dio;
  final String _siteUrl;
  final String _accessToken;

  TasksRepository(this._dio, this._siteUrl, this._accessToken);

  Future<List<SharePointTask>> fetchTasks({String? statusFilter}) async {
    final filter = statusFilter != null
        ? "&\$filter=TaskStatus eq '$statusFilter'"
        : '';
    final response = await _dio.get(
      '$_siteUrl/_api/web/lists/getbytitle(\'Tasks\')/items'
      '?\$select=Id,Title,TaskStatus,DueDate&\$orderby=DueDate asc$filter',
      options: Options(headers: {'Authorization': 'Bearer $_accessToken'}),
    );
    final items = response.data['value'] as List;
    return items.map((j) => SharePointTask.fromJson(j)).toList();
  }
}

// Riverpod AsyncNotifier with code generation
@riverpod
class Tasks extends _$Tasks {
  @override
  Future<List<SharePointTask>> build() async {
    final repo = ref.watch(tasksRepositoryProvider);
    return repo.fetchTasks();
  }

  Future<void> refresh() async {
    ref.invalidateSelf();
    await future;
  }

  Future<void> filterByStatus(String status) async {
    state = const AsyncLoading();
    final repo = ref.read(tasksRepositoryProvider);
    state = await AsyncValue.guard(() => repo.fetchTasks(statusFilter: status));
  }
}

// Widget consuming the AsyncNotifier
class TasksScreen extends ConsumerWidget {
  const TasksScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final tasksAsync = ref.watch(tasksProvider);
    return tasksAsync.when(
      loading: () => const Center(child: CircularProgressIndicator()),
      error: (err, stack) => ErrorView(message: err.toString()),
      data: (tasks) => TaskListView(tasks: tasks),
    );
  }
}

BLoC Pattern: When You Need Strict Separation of Concerns

BLoC — Business Logic Component — was created by the Flutter team at Google and is particularly popular in teams that come from Java, Android, or Angular backgrounds. The pattern enforces a strict unidirectional data flow: UI sends Events to the BLoC, BLoC processes those Events and emits new States, UI rebuilds based on the current State. This is a stream-based reactive architecture, and the flutter_bloc package provides Bloc and Cubit base classes that implement this pattern with excellent tooling support including Bloc observer for logging and the Bloc dev tools.

The strength of BLoC is its enforced discipline. Because the pattern separates Events (inputs) from States (outputs) with an explicit transformation layer (the BLoC itself), it is impossible to accidentally mix business logic into widgets. Each BLoC has a clearly defined contract — here are the events I accept, here are the states I can be in — and that contract is expressed in Dart types. When something goes wrong, the BLoC dev tools show you exactly which events were fired and what state transitions occurred, making debugging significantly easier than tracing through a tangled widget tree.

The weakness of BLoC in enterprise Flutter apps is verbosity. For a simple SharePoint list feature, you'll define an Events sealed class (LoadTasks, RefreshTasks, FilterTasks), a States sealed class (TasksInitial, TasksLoading, TasksLoaded, TasksError), and a BLoC class with an event handler for each. That's three files and significant boilerplate for what Riverpod handles in one file with fewer lines. For teams that value architectural discipline over conciseness and are comfortable with the learning curve, BLoC is excellent. For smaller teams or projects where development speed is paramount, Riverpod wins.

Dart — BLoC Event/State/Bloc Classes
// tasks_event.dart
sealed class TasksEvent {}
class LoadTasksEvent extends TasksEvent {}
class RefreshTasksEvent extends TasksEvent {}
class FilterTasksEvent extends TasksEvent {
  final String status;
  const FilterTasksEvent(this.status);
}

// tasks_state.dart
sealed class TasksState {}
class TasksInitial extends TasksState {}
class TasksLoading extends TasksState {}
class TasksLoaded extends TasksState {
  final List<SharePointTask> tasks;
  const TasksLoaded(this.tasks);
}
class TasksError extends TasksState {
  final String message;
  const TasksError(this.message);
}

// tasks_bloc.dart
class TasksBloc extends Bloc<TasksEvent, TasksState> {
  final TasksRepository _repository;

  TasksBloc(this._repository) : super(TasksInitial()) {
    on<LoadTasksEvent>(_onLoad);
    on<RefreshTasksEvent>(_onRefresh);
    on<FilterTasksEvent>(_onFilter);
  }

  Future<void> _onLoad(LoadTasksEvent event, Emitter<TasksState> emit) async {
    emit(TasksLoading());
    try {
      final tasks = await _repository.fetchTasks();
      emit(TasksLoaded(tasks));
    } catch (e) {
      emit(TasksError(e.toString()));
    }
  }

  Future<void> _onFilter(FilterTasksEvent event, Emitter<TasksState> emit) async {
    emit(TasksLoading());
    try {
      final tasks = await _repository.fetchTasks(statusFilter: event.status);
      emit(TasksLoaded(tasks));
    } catch (e) {
      emit(TasksError(e.toString()));
    }
  }

  Future<void> _onRefresh(RefreshTasksEvent event, Emitter<TasksState> emit) async {
    add(LoadTasksEvent());
  }
}

BLoC vs Riverpod: A Practical Comparison for Enterprise Teams

When enterprise teams ask us to recommend one approach, we walk through five criteria: team background, codebase size, testing requirements, async complexity, and maintenance longevity. Team background is probably the most important factor — a team with strong Dart experience but little reactive programming background will find Riverpod's notifier pattern more natural. A team with RxJava or NgRx experience will feel at home with BLoC immediately. Forcing a team to adopt an unfamiliar paradigm under project pressure is a recipe for poor architecture outcomes regardless of which pattern you choose.

Codebase size matters because BLoC's verbosity scales differently than Riverpod's. In a small app with five or six features, BLoC's three-file-per-feature structure is manageable. In a large enterprise app with forty features, that's 120+ files just for state management — a significant navigation and cognitive overhead. Riverpod's single-file providers with code generation scale much better. We've maintained a large Microsoft 365 mobile app with 35+ features in Riverpod and the codebase remains navigable; the equivalent BLoC structure would be considerably harder to manage.

Testing requirements favor Riverpod in our experience. Riverpod's ProviderContainer allows you to instantiate and test any provider in complete isolation without a widget tree. BLoC testing requires bloc_test and still involves setting up event sequences and expecting state sequences, which is more verbose. For a team that takes testing seriously — and any enterprise team should — Riverpod's testing ergonomics are a meaningful advantage. That said, BLoC's explicit event/state separation makes it easier to write test cases that describe user intent: "when the user loads tasks, expect loading then loaded states."

Integrating SharePoint REST API Calls with Riverpod

In our Flutter Microsoft 365 projects, we follow a consistent pattern for SharePoint REST API integration with Riverpod. We define three layers: a repository that wraps HTTP calls (using Dio with MSAL token injection), a Riverpod provider that exposes the repository, and AsyncNotifier providers for each data entity the app manages. This architecture makes the HTTP layer completely testable in isolation (mock the repository), makes the state layer testable in isolation (mock the provider dependency), and keeps widgets as thin as possible.

Token management with MSAL is handled at the repository level using a token provider that Riverpod manages. We define a msalTokenProvider as a FutureProvider that acquires a token on first access and caches it for subsequent calls. When the token expires, Riverpod's cache invalidation mechanism allows us to force a re-acquisition by calling ref.invalidate(msalTokenProvider). This approach means that token refresh logic lives in one place and is automatically available to every API call in the app.

Testing State Logic: Unit Tests for Notifiers and BLoC

Enterprise Flutter apps must have comprehensive state management tests. State logic is where business rules live — whether a task is overdue, whether a user has permission to edit a document, whether a form is valid before submission. These rules must be tested independently of the UI, and they must be tested exhaustively because failures in state logic directly translate to incorrect user-facing behavior.

Dart — Unit Test for Riverpod AsyncNotifier
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:riverpod/riverpod.dart';

class MockTasksRepository extends Mock implements TasksRepository {}

void main() {
  late MockTasksRepository mockRepo;
  late ProviderContainer container;

  setUp(() {
    mockRepo = MockTasksRepository();
    container = ProviderContainer(
      overrides: [
        // Override the repository with the mock
        tasksRepositoryProvider.overrideWithValue(mockRepo),
      ],
    );
  });

  tearDown(() => container.dispose());

  test('TasksNotifier loads tasks successfully', () async {
    final fakeTasks = [
      SharePointTask(
        id: 1, title: 'Draft Q4 Report',
        status: 'In Progress',
        dueDate: DateTime(2026, 1, 15),
      ),
    ];

    when(() => mockRepo.fetchTasks(statusFilter: null))
        .thenAnswer((_) async => fakeTasks);

    // Wait for the provider to build
    final result = await container.read(tasksProvider.future);

    expect(result, equals(fakeTasks));
    expect(result.first.title, 'Draft Q4 Report');
  });

  test('TasksNotifier emits error state on API failure', () async {
    when(() => mockRepo.fetchTasks(statusFilter: null))
        .thenThrow(Exception('SharePoint API unreachable'));

    final state = await container.read(tasksProvider.future)
        .then((_) => container.read(tasksProvider))
        .catchError((_) => container.read(tasksProvider));

    expect(state, isA<AsyncError>());
  });
}

Key Takeaways

Riverpod 2.x with code generation is the recommended default for new enterprise Flutter apps — better compile-time safety and testing ergonomics.

AsyncNotifier handles loading/data/error state automatically, eliminating manual flag management in SharePoint API integrations.

BLoC is the right choice for teams with reactive programming backgrounds who value strict architectural discipline.

Always test state logic in isolation using ProviderContainer (Riverpod) or bloc_test (BLoC) — never in widget tests.

A

Akshara Technologies

Flutter Microsoft 365 Specialists

We build enterprise Flutter apps that integrate with Microsoft 365 — SharePoint, Graph API, MSAL authentication, and Teams. State management architecture is a critical decision on every project, and we've shipped production apps with all three major patterns.

Building a Flutter App for Microsoft 365?

We design Flutter apps that integrate deeply with SharePoint, Microsoft Graph, and Teams — with architecture that scales as your app grows.

Start the Conversation Our Flutter Services

Related Articles