Riverpod in Flutter: Professional State Management from Basic Provider to Riverpod Generator

Development tutorial - IT technology blog
Development tutorial - IT technology blog

This article gets straight to the code — no lengthy theory. Using the old Provider package and finding state management increasingly messy? Or starting a new project and unsure what to pick? Riverpod 2.x solves both. Let’s dive in.

Get Started: Install Riverpod and Run in 5 Minutes

Add the dependencies to pubspec.yaml:

dependencies:
  flutter_riverpod: ^2.5.1
  riverpod_annotation: ^2.3.5

dev_dependencies:
  riverpod_generator: ^2.4.3
  build_runner: ^2.4.9

Wrap your entire app with ProviderScope — this step is mandatory; skip it and you’ll get an immediate crash:

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

Create your first provider and use it right away:

final counterProvider = StateProvider<int>((ref) => 0);

class CounterWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);
    return Column(
      children: [
        Text('Count: $count'),
        ElevatedButton(
          onPressed: () => ref.read(counterProvider.notifier).state++,
          child: Text('Increment'),
        ),
      ],
    );
  }
}

ConsumerWidget instead of StatelessWidget — that’s the core difference from the old Provider package. Quick start done; let’s move on to more substantial content.

Provider Types and When to Use Each

StateProvider — Simple State

Stick to primitives: bool, int, String, enum. Don’t stuff a List or complex object in here — that’s what NotifierProvider is for:

final isDarkModeProvider = StateProvider<bool>((ref) => false);
final selectedTabProvider = StateProvider<int>((ref) => 0);

FutureProvider — API Calls and Async File Reads

I use this one most often when fetching data from a server — typically through a repository class to abstract the data layer (the Repository Pattern translates cleanly across languages and frameworks). The nice thing is it automatically handles loading/error state — no extra boilerplate needed:

final userListProvider = FutureProvider<List<User>>((ref) async {
  final repo = ref.watch(userRepositoryProvider);
  return repo.fetchAll();
});

// In the widget
class UserList extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final asyncUsers = ref.watch(userListProvider);
    return asyncUsers.when(
      data: (users) => ListView.builder(
        itemCount: users.length,
        itemBuilder: (_, i) => ListTile(title: Text(users[i].name)),
      ),
      loading: () => CircularProgressIndicator(),
      error: (e, _) => Text('Error: $e'),
    );
  }
}

NotifierProvider — Complex Business Logic

StateNotifierProvider was deprecated in Riverpod 2.x — NotifierProvider is its replacement. Simple rule: all state-handling logic lives here, not in your widgets:

class CartNotifier extends Notifier<List<CartItem>> {
  @override
  List<CartItem> build() => [];

  void addItem(CartItem item) {
    state = [...state, item];
  }

  void removeItem(String id) {
    state = state.where((item) => item.id != id).toList();
  }

  double get total => state.fold(0, (sum, item) => sum + item.price);
}

final cartProvider = NotifierProvider<CartNotifier, List<CartItem>>(
  CartNotifier.new,
);

AsyncNotifierProvider — Async State with Side Effects

Login, form submission, checkout — scenarios that need to both load data and trigger side effects. AsyncNotifierProvider handles exactly this:

class AuthNotifier extends AsyncNotifier<User?> {
  @override
  Future<User?> build() async => null;

  Future<void> login(String email, String password) async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(() =>
      ref.read(authRepositoryProvider).login(email, password),
    );
  }

  void logout() => state = const AsyncValue.data(null);
}

Advanced: Riverpod Generator — Write Less Code, Make Fewer Mistakes

I once refactored a 50K-line codebase, and the most expensive lesson was that you need solid test coverage before you start. Riverpod Generator helps enormously here — it auto-generates boilerplate, reduces refactoring risk, and makes intent far clearer than writing it by hand.

Run build_runner in watch mode throughout development:

dart run build_runner watch --delete-conflicting-outputs

Write providers with annotations; build_runner handles the rest:

// user_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'user_provider.g.dart'; // this file is auto-generated

@riverpod
Future<List<User>> userList(UserListRef ref) async {
  return ref.watch(userRepositoryProvider).fetchAll();
}

// Provider with a parameter (replaces family)
@riverpod
Future<User> userById(UserByIdRef ref, String id) async {
  return ref.watch(userRepositoryProvider).getById(id);
}

// Stateful provider using a class
@riverpod
class Cart extends _$Cart {
  @override
  List<CartItem> build() => [];

  void add(CartItem item) => state = [...state, item];
  void remove(String id) => state = state.where((i) => i.id != id).toList();
}

keepAlive — Cache Provider After Widget Disposal

By default with Generator, providers auto-dispose when they lose their last listener. Use keepAlive: true for data that needs caching, like config or user sessions:

@Riverpod(keepAlive: true)
Future<AppConfig> appConfig(AppConfigRef ref) async {
  return ConfigService().load(); // Only loads once
}

Practical Tips from Production Projects

1. Override Providers in Tests — Why I Chose Riverpod

Compared to GetX or BLoC, testing with Riverpod is much cleaner. For end-to-end UI test automation on top of unit tests, Maestro makes Flutter UI testing automation surprisingly painless. No mock framework needed for unit tests. No bloated DI container:

testWidgets('displays user list', (tester) async {
  await tester.pumpWidget(
    ProviderScope(
      overrides: [
        userListProvider.overrideWith((ref) async => [
          User(id: '1', name: 'Test User'),
        ]),
      ],
      child: MaterialApp(home: UserList()),
    ),
  );

  await tester.pumpAndSettle();
  expect(find.text('Test User'), findsOneWidget);
});

2. ref.invalidate() to Refresh Data

ElevatedButton(
  onPressed: () async {
    await ref.read(cartProvider.notifier).checkout();
    ref.invalidate(orderHistoryProvider); // Force re-fetch
    ref.invalidate(cartProvider);          // Reset cart to initial state
  },
  child: Text('Checkout'),
)

3. Avoid watch in Callbacks — The Most Common Mistake

// ❌ Wrong — throws an exception immediately
ElevatedButton(
  onPressed: () {
    final user = ref.watch(userProvider); // NEVER watch inside a callback
  },
);

// ✅ Correct — use read inside a callback
ElevatedButton(
  onPressed: () {
    final user = ref.read(userProvider);
  },
);

4. Listen to Side Effects with listenManual

In ConsumerStatefulWidget, use ref.listenManual in initState to react to state changes without triggering a full widget rebuild:

@override
void initState() {
  super.initState();
  ref.listenManual(authProvider, (previous, next) {
    // Navigate when auth state changes
    if (next.valueOrNull == null) {
      Navigator.pushReplacementNamed(context, '/login');
    }
  });
}

5. Organize Folders by Feature, Not by Type

lib/
  features/
    auth/
      providers/    ← auth_provider.dart + auth_provider.g.dart
      models/
      repositories/
      screens/
    cart/
      providers/
      models/
    ...

This structure scales far better than dumping all providers into a single folder. Need to remove a feature? Delete the whole folder and you’re done — no hunting for scattered files across the project.

Still on the old Provider package? Migrate one feature at a time — don’t do a bulk migration. From that 50K-line refactor: tackle it in small pieces, keep tests thorough, and rolling back is 10x easier than rewriting everything at once and then having no idea where the bugs are coming from.

Share: