Let's be honest: Flutter state management has been a mess.

setState? Too simple for real apps.

BLoC? Too much boilerplate.

Provider? Better, but confusing.

Redux? Welcome to ceremony hell.

Then Riverpod arrived and said: "What if state management was actually... pleasant?"

Riverpod (an anagram of "Provider," get it?) is Flutter's modern state management solution. No BuildContext dependency. Compile-time safety. Easy testing. Zero boilerplate with code generation. It's become the go-to pattern in modern mobile app state management across Flutter projects.

Think of state management like managing mpesa transactions at a busy shop. setState is writing everything in one notebook (chaos). Riverpod is having a proper system - different counters for different services, instant notifications when money moves, automatic receipts. Everything organized, everything reactive.

Today we're mastering Riverpod 3.0 (released September 2025) with real-world examples you'll actually use.

Setup: Add Riverpod to Your Flutter Project

pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^3.0.0  # Core Riverpod for Flutter

dev_dependencies:
  riverpod_generator: ^3.0.0  # Code generation (optional but recommended)
  build_runner: ^2.4.0  # Required for code generation
  riverpod_lint: ^3.0.0  # Linting rules

Run:

flutter pub get

Core Concepts: Providers Explained

Providers are objects that encapsulate state and expose it to your UI. Think of them as smart variables that notify listeners when they change.

1. Provider (Immutable Value)

For values that never change:

import 'package:flutter_riverpod/flutter_riverpod.dart';

// Simple value
final apiUrlProvider = Provider<String>((ref) {
  return 'https://api.myapp.com';
});

// Computed value
final greetingProvider = Provider<String>((ref) {
  final hour = DateTime.now().hour;
  if (hour < 12) return 'Good morning!';
  if (hour < 18) return 'Good afternoon!';
  return 'Good evening!';
});

2. StateProvider (Mutable Simple State)

For simple state that changes (like a counter):

// Counter that can be incremented/decremented
final counterProvider = StateProvider<int>((ref) => 0);

// Theme mode toggle
final isDarkModeProvider = StateProvider<bool>((ref) => false);

3. FutureProvider (Async Data)

For async operations that complete once:

// Fetch user profile
final userProvider = FutureProvider<User>((ref) async {
  final api = ref.watch(apiServiceProvider);
  return await api.getUser();
});

// Fetch weather data
final weatherProvider = FutureProvider<Weather>((ref) async {
  final response = await http.get(Uri.parse('https://api.weather.com/current'));
  return Weather.fromJson(jsonDecode(response.body));
});

4. StreamProvider (Continuous Updates)

For streams of data:

// Real-time location updates
final locationProvider = StreamProvider<Position>((ref) {
  return Geolocator.getPositionStream();
});

// Firestore real-time updates
final messagesProvider = StreamProvider<List<Message>>((ref) {
  return FirebaseFirestore.instance
      .collection('messages')
      .orderBy('timestamp', descending: true)
      .snapshots()
      .map((snapshot) => snapshot.docs.map((doc) => Message.fromFirestore(doc)).toList());
});

5. StateNotifierProvider (Complex State Logic)

For complex state with custom logic:

// State class
class Counter extends StateNotifier<int> {
  Counter() : super(0);

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

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

Real-World Example: Todo App with Riverpod

Let's build a complete todo app with add, toggle, filter, and delete functionality.

1. Models:

// lib/models/todo.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'todo.freezed.dart';
part 'todo.g.dart';

@freezed
class Todo with _$Todo {
  const factory Todo({
    required String id,
    required String title,
    @Default(false) bool completed,
  }) = _Todo;

  factory Todo.fromJson(Map<String, dynamic> json) => _$TodoFromJson(json);
}

enum TodoFilter { all, active, completed }

2. State Management:

// lib/providers/todo_providers.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/todo.dart';

// Todo list state
class TodoList extends StateNotifier<List<Todo>> {
  TodoList() : super([]);

  void add(String title) {
    state = [
      ...state,
      Todo(
        id: DateTime.now().toString(),
        title: title,
      ),
    ];
  }

  void toggle(String id) {
    state = [
      for (final todo in state)
        if (todo.id == id)
          todo.copyWith(completed: !todo.completed)
        else
          todo,
    ];
  }

  void remove(String id) {
    state = state.where((todo) => todo.id != id).toList();
  }
}

// Providers
final todoListProvider = StateNotifierProvider<TodoList, List<Todo>>((ref) {
  return TodoList();
});

final todoFilterProvider = StateProvider<TodoFilter>((ref) => TodoFilter.all);

// Computed/filtered todos
final filteredTodosProvider = Provider<List<Todo>>((ref) {
  final filter = ref.watch(todoFilterProvider);
  final todos = ref.watch(todoListProvider);

  switch (filter) {
    case TodoFilter.all:
      return todos;
    case TodoFilter.active:
      return todos.where((todo) => !todo.completed).toList();
    case TodoFilter.completed:
      return todos.where((todo) => todo.completed).toList();
  }
});

// Stats
final uncompletedTodosCountProvider = Provider<int>((ref) {
  final todos = ref.watch(todoListProvider);
  return todos.where((todo) => !todo.completed).length;
});

3. UI - Main Screen:

// lib/screens/todo_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/todo_providers.dart';
import '../models/todo.dart';

class TodoScreen extends ConsumerWidget {
  const TodoScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final todos = ref.watch(filteredTodosProvider);
    final uncompletedCount = ref.watch(uncompletedTodosCountProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Riverpod Todos'),
        actions: [
          // Stats badge
          Center(
            child: Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: Text(
                '$uncompletedCount items left',
                style: const TextStyle(fontSize: 14),
              ),
            ),
          ),
        ],
      ),
      body: Column(
        children: [
          // Add todo input
          const TodoInput(),

          // Filter tabs
          const TodoFilterTabs(),

          // Todo list
          Expanded(
            child: todos.isEmpty
                ? const Center(child: Text('No todos yet!'))
                : ListView.builder(
                    itemCount: todos.length,
                    itemBuilder: (context, index) {
                      return TodoItem(todo: todos[index]);
                    },
                  ),
          ),
        ],
      ),
    );
  }
}

4. UI - Components:

// Add todo input
class TodoInput extends ConsumerStatefulWidget {
  const TodoInput({Key? key}) : super(key: key);

  @override
  ConsumerState<TodoInput> createState() => _TodoInputState();
}

class _TodoInputState extends ConsumerState<TodoInput> {
  final _controller = TextEditingController();

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _addTodo() {
    if (_controller.text.trim().isEmpty) return;

    ref.read(todoListProvider.notifier).add(_controller.text.trim());
    _controller.clear();
  }

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(16),
      child: TextField(
        controller: _controller,
        decoration: InputDecoration(
          hintText: 'What needs to be done?',
          suffixIcon: IconButton(
            icon: const Icon(Icons.add),
            onPressed: _addTodo,
          ),
        ),
        onSubmitted: (_) => _addTodo(),
      ),
    );
  }
}

// Filter tabs
class TodoFilterTabs extends ConsumerWidget {
  const TodoFilterTabs({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final filter = ref.watch(todoFilterProvider);

    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: [
        _FilterButton(
          label: 'All',
          isSelected: filter == TodoFilter.all,
          onTap: () => ref.read(todoFilterProvider.notifier).state = TodoFilter.all,
        ),
        _FilterButton(
          label: 'Active',
          isSelected: filter == TodoFilter.active,
          onTap: () => ref.read(todoFilterProvider.notifier).state = TodoFilter.active,
        ),
        _FilterButton(
          label: 'Completed',
          isSelected: filter == TodoFilter.completed,
          onTap: () => ref.read(todoFilterProvider.notifier).state = TodoFilter.completed,
        ),
      ],
    );
  }
}

class _FilterButton extends StatelessWidget {
  final String label;
  final bool isSelected;
  final VoidCallback onTap;

  const _FilterButton({
    required this.label,
    required this.isSelected,
    required this.onTap,
  });

  @override
  Widget build(BuildContext context) {
    return TextButton(
      onPressed: onTap,
      style: TextButton.styleFrom(
        foregroundColor: isSelected ? Colors.blue : Colors.grey,
        textStyle: TextStyle(
          fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
        ),
      ),
      child: Text(label),
    );
  }
}

// Todo item
class TodoItem extends ConsumerWidget {
  final Todo todo;

  const TodoItem({Key? key, required this.todo}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Dismissible(
      key: Key(todo.id),
      background: Container(
        color: Colors.red,
        alignment: Alignment.centerRight,
        padding: const EdgeInsets.only(right: 16),
        child: const Icon(Icons.delete, color: Colors.white),
      ),
      direction: DismissDirection.endToStart,
      onDismissed: (_) {
        ref.read(todoListProvider.notifier).remove(todo.id);
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('${todo.title} deleted')),
        );
      },
      child: ListTile(
        leading: Checkbox(
          value: todo.completed,
          onChanged: (_) {
            ref.read(todoListProvider.notifier).toggle(todo.id);
          },
        ),
        title: Text(
          todo.title,
          style: TextStyle(
            decoration: todo.completed ? TextDecoration.lineThrough : null,
            color: todo.completed ? Colors.grey : null,
          ),
        ),
      ),
    );
  }
}

5. Main App Setup:

// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'screens/todo_screen.dart';

void main() {
  runApp(
    // Wrap entire app with ProviderScope
    const ProviderScope(
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Riverpod Todo',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: const TodoScreen(),
    );
  }
}

Advanced Patterns

1. Async Data with Loading States

class UserRepository {
  Future<User> fetchUser(String id) async {
    await Future.delayed(const Duration(seconds: 2)); // Simulate API call
    return User(id: id, name: 'John Kamau', email: 'john@example.com');
  }
}

final userRepositoryProvider = Provider((ref) => UserRepository());

final userProvider = FutureProvider.family<User, String>((ref, userId) async {
  final repository = ref.watch(userRepositoryProvider);
  return await repository.fetchUser(userId);
});

// In UI
class UserProfile extends ConsumerWidget {
  final String userId;

  const UserProfile({Key? key, required this.userId}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final userAsync = ref.watch(userProvider(userId));

    return userAsync.when(
      data: (user) => Column(
        children: [
          Text(user.name, style: const TextStyle(fontSize: 24)),
          Text(user.email),
        ],
      ),
      loading: () => const CircularProgressIndicator(),
      error: (error, stack) => Text('Error: $error'),
    );
  }
}

2. Refresh and Invalidate

// In UI - Pull to refresh
RefreshIndicator(
  onRefresh: () async {
    // Invalidate provider to refetch
    ref.invalidate(userProvider);
  },
  child: ListView(...),
)

// Or manually refresh
ElevatedButton(
  onPressed: () {
    ref.refresh(userProvider(userId));
  },
  child: const Text('Refresh'),
)

3. Provider Dependencies

// API service provider
final apiServiceProvider = Provider((ref) => ApiService());

// Auth provider that depends on API service
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
  final api = ref.watch(apiServiceProvider);
  return AuthNotifier(api);
});

// User provider that depends on auth
final currentUserProvider = FutureProvider<User?>((ref) async {
  final authState = ref.watch(authProvider);

  if (authState is Authenticated) {
    final api = ref.watch(apiServiceProvider);
    return await api.getCurrentUser();
  }

  return null;
});

4. Code Generation (Recommended for Production)

// lib/providers/counter_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'counter_provider.g.dart';

@riverpod
class Counter extends _$Counter {
  @override
  int build() => 0;

  void increment() => state++;
  void decrement() => state--;
}

// Generate code:
// flutter pub run build_runner build

Testing with Riverpod

import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  test('Counter increments correctly', () {
    final container = ProviderContainer();
    addTearDown(container.dispose);

    // Initial value
    expect(container.read(counterProvider), 0);

    // Increment
    container.read(counterProvider.notifier).increment();
    expect(container.read(counterProvider), 1);

    // Increment again
    container.read(counterProvider.notifier).increment();
    expect(container.read(counterProvider), 2);
  });

  test('Filtered todos work correctly', () {
    final container = ProviderContainer();
    addTearDown(container.dispose);

    // Add todos
    container.read(todoListProvider.notifier).add('Buy milk');
    container.read(todoListProvider.notifier).add('Walk dog');

    // All todos
    expect(container.read(filteredTodosProvider).length, 2);

    // Complete one
    final firstTodo = container.read(todoListProvider)[0];
    container.read(todoListProvider.notifier).toggle(firstTodo.id);

    // Filter completed
    container.read(todoFilterProvider.notifier).state = TodoFilter.completed;
    expect(container.read(filteredTodosProvider).length, 1);

    // Filter active
    container.read(todoFilterProvider.notifier).state = TodoFilter.active;
    expect(container.read(filteredTodosProvider).length, 1);
  });
}

Best Practices

1. Use ConsumerWidget instead of StatelessWidget:

// ✅ DO
class MyWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final value = ref.watch(myProvider);
    // ...
  }
}

2. Read vs Watch:

// watch - rebuilds when provider changes (use in build)
final count = ref.watch(counterProvider);

// read - one-time read (use in callbacks/methods)
onPressed: () => ref.read(counterProvider.notifier).increment()

3. Dispose resources:

final databaseProvider = Provider((ref) {
  final database = Database();

  ref.onDispose(() {
    database.close();
  });

  return database;
});

Common Mistakes

Using ref.read in build method

// Wrong - won't rebuild on changes
final count = ref.read(counterProvider);

Use ref.watch in build

final count = ref.watch(counterProvider);

Not wrapping app with ProviderScope

runApp(MyApp()); // Crash!

Always wrap with ProviderScope

runApp(ProviderScope(child: MyApp()));

The Bottom Line

Riverpod makes state management in Flutter actually pleasant:

  • No BuildContext dependency
  • Compile-time safety
  • Easy testing
  • Zero boilerplate with code generation
  • Great DevTools integration

When deciding between mobile-first or web-first development, Riverpod's architecture works beautifully for both Flutter mobile and Flutter web. Pair it with strong Dart null safety patterns to eliminate runtime null errors, and remember that state architecture should support good user experience, not fight against it.

You've seen how to:

  • Use different provider types
  • Build a complete todo app
  • Handle async data
  • Test providers
  • Follow best practices

State management doesn't have to be painful. With Riverpod, it's just... manageable.

Now go build something stateful!

Sources: