Skip to content

Flutter State Management — Provider, Riverpod, and BLoC

DodaTech Updated 2026-06-28 8 min read

In this tutorial, you will learn about Flutter State Management. We cover key concepts, practical examples, and best practices to help you master this topic.

Flutter state management solutions separate business logic from UI code by storing mutable state outside widgets and notifying listeners when state changes, enabling reactive updates.

What Will You Learn

  • The problem state management solves
  • Provider for Dependency Injection and state exposure
  • Riverpod as a compile-safe Provider alternative
  • BLoC pattern with events and states
  • Comparing state management approaches
  • When to use each solution

Why It Matters

As Flutter apps grow beyond simple counter demos, managing state with setState becomes impractical. State shared across screens, derived state, async state, and complex state transitions require a structured approach. Without proper state management, the widget tree becomes tangled with business logic, making the app hard to maintain and test. Provider is the recommended solution for most apps by the Flutter team, while Riverpod offers improvements and BLoC is popular in enterprise applications.

Real-World Use

The DodaTech Flutter app uses Riverpod for state management. A coursesProvider fetches course data from the API and caches it. A searchQueryProvider holds the current search text. A derived filteredCoursesProvider combines both providers and returns only matching courses. When the user types in the search bar, the filtered list updates reactively without manual setState calls.

Learning Path

flowchart LR
  A[Flutter Scrolling] --> B[State Management\nYou are here]
  B --> C[Flutter Navigation]
  style B fill:#f90,color:#fff

The State Management Problem

Without state management, widgets hold state and pass it down through constructors:

// Problem: state is scattered and hard to share
class ParentWidget extends StatefulWidget {
  @override
  State<ParentWidget> createState() => _ParentWidgetState();
}

class _ParentWidgetState extends State<ParentWidget> {
  int _counter = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('$_counter'),
        ChildWidget(counter: _counter),
        ElevatedButton(
          onPressed: () => setState(() => _counter++),
          child: Text('Increment'),
        ),
      ],
    );
  }
}

This works for simple cases but fails when: the counter needs to be shared with a deeply nested widget, persisted across screens, or derived from multiple sources. State management solutions solve these issues.

Provider

Provider exposes values to the widget tree via BuildContext. It is the officially recommended solution:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

// Model
class CounterModel extends ChangeNotifier {
  int _count = 0;
  int get count => _count;

  void increment() {
    _count++;
    notifyListeners();
  }
}

// App setup
void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => CounterModel(),
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: CounterScreen(),
    );
  }
}

// Consumer widget
class CounterScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Provider')),
      body: Center(
        child: Consumer<CounterModel>(
          builder: (context, counter, child) {
            return Text(
              'Count: ${counter.count}',
              style: TextStyle(fontSize: 48),
            );
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          context.read<CounterModel>().increment();
        },
        child: Icon(Icons.add),
      ),
    );
  }
}

ChangeNotifierProvider provides a CounterModel instance to the widget tree. Consumer<CounterModel> rebuilds when notifyListeners() is called. context.read<T>() accesses the provider without listening to changes.

MultiProvider

Use MultiProvider to expose multiple models:

void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => AuthModel()),
        ChangeNotifierProvider(create: (_) => CartModel()),
        ChangeNotifierProvider(create: (_) => SettingsModel()),
      ],
      child: MyApp(),
    ),
  );
}

Each ChangeNotifierProvider creates a separate model. Widgets consume only the providers they need.

Provider with Services

Provider handles dependency injection for services:

class ApiService {
  Future<List<String>> fetchItems() async {
    await Future.delayed(Duration(seconds: 1));
    return ['Item 1', 'Item 2', 'Item 3'];
  }
}

class ItemModel extends ChangeNotifier {
  final ApiService _api;
  List<String> _items = [];
  bool _isLoading = false;

  ItemModel(this._api);

  List<String> get items => _items;
  bool get isLoading => _isLoading;

  Future<void> loadItems() async {
    _isLoading = true;
    notifyListeners();

    _items = await _api.fetchItems();
    _isLoading = false;
    notifyListeners();
  }
}

void main() {
  runApp(
    MultiProvider(
      providers: [
        Provider(create: (_) => ApiService()),
        ChangeNotifierProxyProvider<ApiService, ItemModel>(
          create: (context) => ItemModel(context.read<ApiService>()),
        ),
      ],
      child: MyApp(),
    ),
  );
}

ProxyProvider creates a model that depends on another provider. Provider (without ChangeNotifier) is used for plain objects like services.

Riverpod

Riverpod is a compile-safe, testable alternative to Provider:

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// Provider definition
final counterProvider = StateNotifierProvider<CounterNotifier, int>((ref) {
  return CounterNotifier();
});

class CounterNotifier extends StateNotifier<int> {
  CounterNotifier() : super(0);

  void increment() => state++;
  void reset() => state = 0;
}

// App setup
void main() {
  runApp(
    ProviderScope(
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(home: CounterScreen());
  }
}

// Consumer widget
class CounterScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);

    return Scaffold(
      appBar: AppBar(title: Text('Riverpod')),
      body: Center(
        child: Text('Count: $count', style: TextStyle(fontSize: 48)),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => ref.read(counterProvider.notifier).increment(),
        child: Icon(Icons.add),
      ),
    );
  }
}

ProviderScope wraps the app. ConsumerWidget replaces StatelessWidget and provides WidgetRef. ref.watch() listens to changes and rebuilds the widget. ref.read() accesses state without listening.

Derived State with Riverpod

Riverpod supports derived state and async state natively:

final searchQueryProvider = StateProvider<String>((ref) => '');

final itemsProvider = FutureProvider<List<String>>((ref) async {
  await Future.delayed(Duration(seconds: 1));
  return ['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry', 'Fig', 'Grape'];
});

final filteredItemsProvider = FutureProvider<List<String>>((ref) async {
  final query = ref.watch(searchQueryProvider).toLowerCase();
  final items = await ref.watch(itemsProvider.future);

  if (query.isEmpty) return items;
  return items.where((item) => item.toLowerCase().contains(query)).toList();
});

class SearchScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final filtered = ref.watch(filteredItemsProvider);

    return Scaffold(
      appBar: AppBar(title: Text('Search')),
      body: Column(
        children: [
          TextField(
            onChanged: (value) => ref.read(searchQueryProvider.notifier).state = value,
            decoration: InputDecoration(
              hintText: 'Search...',
              prefixIcon: Icon(Icons.search),
            ),
          ),
          Expanded(
            child: filtered.when(
              data: (items) => ListView.builder(
                itemCount: items.length,
                itemBuilder: (_, i) => ListTile(title: Text(items[i])),
              ),
              loading: () => Center(child: CircularProgressIndicator()),
              error: (err, _) => Center(child: Text('Error: $err')),
            ),
          ),
        ],
      ),
    );
  }
}

FutureProvider handles async operations with loading, data, and error states. Derived providers automatically recompute when their dependencies change. The search UI updates reactively as the user types.

BLoC Pattern

BLoC (Business Logic Component) uses streams for state management:

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

// Events
abstract class CounterEvent {}
class IncrementEvent extends CounterEvent {}
class DecrementEvent extends CounterEvent {}

// State
class CounterState {
  final int count;
  CounterState(this.count);
}

// BLoC
class CounterBloc extends Bloc<CounterEvent, CounterState> {
  CounterBloc() : super(CounterState(0)) {
    on<IncrementEvent>((event, emit) {
      emit(CounterState(state.count + 1));
    });
    on<DecrementEvent>((event, emit) {
      emit(CounterState(state.count - 1));
    });
  }
}

void main() {
  runApp(
    BlocProvider(
      create: (_) => CounterBloc(),
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(home: CounterScreen());
  }
}

class CounterScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('BLoC')),
      body: Center(
        child: BlocBuilder<CounterBloc, CounterState>(
          builder: (context, state) {
            return Text('Count: ${state.count}', style: TextStyle(fontSize: 48));
          },
        ),
      ),
      floatingActionButton: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          FloatingActionButton(
            onPressed: () => context.read<CounterBloc>().add(IncrementEvent()),
            child: Icon(Icons.add),
          ),
          SizedBox(height: 8),
          FloatingActionButton(
            onPressed: () => context.read<CounterBloc>().add(DecrementEvent()),
            child: Icon(Icons.remove),
          ),
        ],
      ),
    );
  }
}

BLoC separates events (user actions) from state (UI data). BlocProvider provides the bloc. BlocBuilder rebuilds the widget on state changes. BLoC is testable because events and state are plain objects.

Choosing the Right Solution

Solution Best for Complexity Testing
setState Simple local state Low Hard
Provider Medium apps, DI Medium Medium
Riverpod Complex apps, compiled safety Medium Easy
BLoC Enterprise, event-driven High Easy
GetX Quick prototyping Low Hard

For most apps, start with Provider or Riverpod. BLoC is preferred in large teams where event traceability matters. GetX is controversial due to its monolithic nature.

Common Mistakes

  1. Putting all state in one provider: Split state into focused providers. A user provider, a settings provider, and a cart provider are easier to maintain than a single mega-provider.

  2. Using context.read in build methods: context.read<T>() should only be used in callbacks. Use context.watch<T>() or Consumer<T> in build methods to listen to changes.

  3. Not disposing providers that have streams: Providers that create stream subscriptions, timers, or controllers should implement dispose to clean up resources.

  4. Creating providers inside build methods: Providers should be created once and stored. Creating them in build causes state loss on every rebuild.

  5. Over-engineering with BLoC for simple apps: BLoC adds boilerplate. For a counter app, setState is sufficient. Choose the solution that matches the app's complexity.

Practice Questions

  1. How does Provider's ChangeNotifierProvider notify listeners of state changes?
  2. What is the difference between ref.watch and ref.read in Riverpod?
  3. How does BLoC separate events from state?
  4. When would you use Provider over Riverpod?
  5. Challenge: Build a todo list app with Riverpod. States: loading, loaded (with todo items), error. Actions: add todo, toggle completion, delete todo. Use FutureProvider for initial load and StateNotifierProvider for mutations.

Mini Project

Build a shopping cart app with Provider:

  • ProductModel and CartModel as ChangeNotifiers
  • Product list screen showing available products
  • Cart screen showing added items and total
  • Add to cart and remove from cart functionality
  • Cart badge on app bar showing item count
  • Persist cart using SharedPreferences
  • Write unit tests for CartModel

FAQ

What is the difference between Provider and Riverpod?

Riverpod is compile-safe (no runtime errors from missing providers), supports autodispose, and does not depend on BuildContext. Provider is simpler and officially recommended.

Can I use multiple state management solutions together?

Yes. It is common to use Provider for DI and Riverpod for state, or BLoC for complex features and setState for simple widget-local state.

How do I test a ChangeNotifier?

Create an instance, call methods, and assert on property values. ChangeNotifier is a plain Dart class and requires no Flutter dependencies for unit testing.

What is the purpose of `notifyListeners()`?

ChangeNotifier calls notifyListeners() to tell all registered listeners that the state has changed. Provider's Consumer widget registers itself as a listener.

How does BLoC handle async operations?

BLoC uses Stream internally. Use async event handlers with emit for async operations. The BlocListener widget handles side effects like navigation.

What is Next

Now that you understand state management, learn about navigation. Proceed to Flutter Navigation for routing, named routes, and deep linking. Then explore Flutter Forms for input handling and validation.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro