You’ve been building with setState and things have been fine. A counter, a to-do list, a notes app. Then you hit the wall.
You have a shopping cart count in CartScreen and you need it in the AppBar badge on HomeScreen. So you lift the state to the parent. Now the parent needs a callback. The callback needs to be passed to a child. That child passes it to its child. Four layers of constructors for one integer. You start thinking “there must be a better way” — and you’d be right.
That better way is Riverpod. Declare your state once, at the top level. Any widget in the entire app reads it directly — no passing, no lifting, no callbacks. When the state changes, only the widgets that actually care about it rebuild. Everything else stays still.
This flutter riverpod for beginners tutorial starts from zero: why setState breaks down, what Riverpod actually is, and a step-by-step build from a simple counter to a real notes app with async data. No StateNotifier, no deprecated APIs — just the 2026 Riverpod you’ll actually use.
- StatefulWidget, setState, and initState — you need to know what Riverpod replaces
- Dart async/await and Future — needed for FutureProvider
- Know how to add packages to pubspec.yaml
1. The setState Ceiling — Three Pain Points
setState is not wrong — Flutter’s docs still teach it and you’ll keep using it for widget-local UI state. The problem is when state needs to escape the widget that owns it. Here are the three exact walls beginners hit:
Pain Point 1: State shared between screens
// Problem: CartScreen has the cart count. AppBar in HomeScreen needs it.
// setState only rebuilds the widget that calls it — other widgets are blind to the change.
// The setState "solution":
// 1. Lift cart state to the parent of both screens
// 2. Pass count down to HomeScreen as a constructor param
// 3. Pass it again from HomeScreen to AppBar
// 4. Pass a callback up from CartScreen to parent to update the count
// Result: 4 layers of indirection for one integer ↑
Pain Point 2: Prop-drilling death march
// Problem: a deeply nested button needs to modify state owned by a grandparent widget.
// You pass callbacks down through every level even if intermediate widgets don't need them.
class GrandParent extends StatefulWidget { ... }
class _GrandParentState extends State<GrandParent> {
bool _isDark = false;
@override
Widget build(BuildContext context) {
// Parent doesn't need isDark or onToggle — it just passes them through
return Parent(isDark: _isDark, onToggle: (v) => setState(() => _isDark = v));
}
}
class Parent extends StatelessWidget {
final bool isDark; // doesn't use this
final Function(bool) onToggle; // doesn't use this
// Exists only to pass them to Child ↓
}
class Child extends StatelessWidget {
final bool isDark;
final Function(bool) onToggle;
// FINALLY uses them here ↑
// But if there's a GrandChild, you pass again...
}
Pain Point 3: Async state spaghetti
// Problem: async data + error handling + loading state + setState = nested nightmare
class _MyScreenState extends State<MyScreen> {
bool _isLoading = true;
String? _error;
List<String> _items = [];
@override
void initState() {
super.initState();
_loadData();
}
Future<void> _loadData() async {
try {
final items = await ApiService().fetchItems();
setState(() { _items = items; _isLoading = false; });
} catch (e) {
setState(() { _error = '$e'; _isLoading = false; });
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) return const CircularProgressIndicator();
if (_error != null) return Text('Error: $_error');
return ListView.builder(...); // 3 booleans, 2 setState calls, 1 FutureBuilder replaced by this maze
}
}
// With Riverpod FutureProvider: this entire class becomes 4 lines.
setState — state that lives in one widget and never needs to be seen anywhere else (a form field, an animation toggle, a text controller)Riverpod — state that multiple widgets read, state that changes across screens, or async data from an API or database
2. Provider → Riverpod: A One-Paragraph History
Provider was created by Rémi Rousselet and became Flutter’s most-downloaded state management package — so popular the Flutter team added it to their official docs. It solved prop-drilling brilliantly using InheritedWidget, but had real limitations: errors when a provider wasn’t found were runtime crashes, not compile-time errors; it depended on BuildContext, making it hard to use outside widgets; and composing multiple providers was messy. So Rémi built Riverpod — literally an anagram of “Provider” — to fix all of Provider’s limitations while keeping the same mental model. The key architectural difference: Provider lives in the Flutter widget tree; Riverpod lives outside it. In 2026, Rémi and the Flutter team explicitly recommend Riverpod over Provider for all new projects. Provider is still maintained but it is no longer the entry point.
3. What Riverpod Actually Is
Riverpod has two core concepts. Everything else is built on top of them:
| Concept | What it is | Where you write it |
|---|---|---|
| Provider | A Dart object declared globally that holds or computes state. Lazy (created only when first read), can depend on other providers | Top-level final variable, outside any class |
| Ref | An object that lets you interact with providers — read, watch, or listen. Widgets get WidgetRef; providers get Ref | Received automatically in build(context, ref) or in provider callbacks |
// The mental model in code:
// 1. Declare a provider globally (outside any class — this is important)
final counterProvider = StateProvider<int>((ref) => 0);
// ↑ type ↑ initial value via ref
// 2. Any widget anywhere in the app reads it via ref
class AnyWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider); // ← reads AND subscribes
return Text('$count'); // rebuilds when count changes
}
}
// 3. Any other widget updates it — no passing, no lifting
class AnotherWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton(
onPressed: () => ref.read(counterProvider.notifier).state++,
child: const Text('+'),
);
}
}
// AnyWidget and AnotherWidget can be on completely different screens.
// Neither knows the other exists. The provider is the single source of truth.
4. Setup: Installation and ProviderScope
# pubspec.yaml
dependencies:
flutter_riverpod: ^2.6.1 # check pub.dev for latest
flutter pub get
import 'package:flutter_riverpod/flutter_riverpod.dart';
The only app-level change needed is wrapping runApp with ProviderScope. That’s it — one line to initialise all of Riverpod:
void main() {
runApp(
const ProviderScope( // ← wrap runApp with this — nothing else needed
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Riverpod Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: const CounterPage(),
);
}
}
// ProviderScope stores ALL provider state for the entire app.
// It can be nested to override provider values in sub-trees — useful for testing.
5. Provider Types — The Reference Card
Many Google results still show
StateNotifierProvider and the StateNotifier class — this is the old Riverpod 1.x API. As of Riverpod 2.0+, use NotifierProvider instead. This post only teaches the current API.
| Provider | State mutable? | Async? | Use for |
|---|---|---|---|
Provider | No | No | Read-only values, services, derived state |
StateProvider | Yes | No | Simple mutable state — counter, toggle, selected tab |
FutureProvider | No (auto) | Yes | One-shot async data — API call, DB load |
StreamProvider | No (auto) | Yes | Ongoing async data — Firestore stream, WebSocket |
NotifierProvider | Yes | No | Complex mutable state with multiple methods |
AsyncNotifierProvider | Yes | Yes | Complex mutable state + async operations |
For this beginner tutorial, focus on StateProvider, Provider, and FutureProvider — these cover 90% of what you’ll need in a first real app. NotifierProvider is introduced briefly in the Notes app upgrade section.
6. ConsumerWidget, ConsumerStatefulWidget, and Consumer
Riverpod adds three widget types alongside Flutter’s existing ones. Pick the right one based on what your widget needs:
// ── ConsumerWidget: replaces StatelessWidget ──────────────────────────────
// Use when: you only need to read providers (no lifecycle methods needed)
// build() receives an extra WidgetRef ref argument
class CounterDisplay extends ConsumerWidget {
const CounterDisplay({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) { // ← ref added here
final count = ref.watch(counterProvider);
return Text(
'$count',
style: const TextStyle(fontSize: 60, fontWeight: FontWeight.bold),
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// ── ConsumerStatefulWidget + ConsumerState: replaces StatefulWidget ───────
// Use when: you need BOTH lifecycle methods (initState, dispose) AND providers
// Common for: controllers, animations, one-time side effects on mount
class SearchScreen extends ConsumerStatefulWidget {
const SearchScreen({super.key});
@override
ConsumerState<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends ConsumerState<SearchScreen> {
final _controller = TextEditingController();
@override
void initState() {
super.initState();
// ref is available directly in ConsumerState — no need to pass it
final initialQuery = ref.read(searchQueryProvider);
_controller.text = initialQuery;
}
@override
void dispose() {
_controller.dispose(); // ← dispose still works normally
super.dispose();
}
@override
Widget build(BuildContext context) {
final results = ref.watch(searchResultsProvider); // watch in build as normal
return Column(
children: [
TextField(controller: _controller),
Text('${results.length} results'),
],
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// ── Consumer: inline, wraps only part of a StatelessWidget tree ────────────
// Use when: most of the widget is static, only one section needs to rebuild
class ProductPage extends StatelessWidget {
const ProductPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
// AppBar is completely static — no reason to rebuild it
appBar: AppBar(title: const Text('Products')),
body: Column(
children: [
const HeaderBanner(), // static, never rebuilds
// Only THIS part rebuilds when cartProvider changes
Consumer(
builder: (context, ref, child) {
final cartCount = ref.watch(cartProvider).length;
return CartSummaryBar(count: cartCount);
},
),
const ProductGrid(), // static, never rebuilds
],
),
);
}
}
7. ref.watch vs ref.read vs ref.listen — The Core Section
This is the most-Googled Riverpod question. Get this right and everything else falls into place:
| Method | Subscribes? | Rebuilds widget? | Use in |
|---|---|---|---|
ref.watch | Yes | Yes — on every change | build() only |
ref.read | No | No | Callbacks: onPressed, onChanged, initState |
ref.listen | Yes | No — runs a callback | build() for side effects (navigate, SnackBar, log) |
class CounterPage extends ConsumerWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// ── ref.watch ────────────────────────────────────────────────────────
// Read AND subscribe. Widget rebuilds when counterProvider changes.
// ONLY use in build() — never inside a callback.
final count = ref.watch(counterProvider);
// ── ref.listen ────────────────────────────────────────────────────────
// Subscribe but run a CALLBACK instead of rebuilding.
// Use in build() when you want a side effect on change.
ref.listen<int>(counterProvider, (previous, next) {
if (next >= 10) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('🎉 You reached 10!')),
);
}
});
return Scaffold(
appBar: AppBar(title: const Text('Counter')),
body: Center(
child: Text('$count', style: const TextStyle(fontSize: 72)),
),
floatingActionButton: Column(
mainAxisSize: MainAxisSize.min,
children: [
FloatingActionButton(
// ── ref.read ──────────────────────────────────────────────────
// Read ONCE without subscribing. Use in callbacks — never in build().
onPressed: () => ref.read(counterProvider.notifier).state++,
child: const Icon(Icons.add),
),
const SizedBox(height: 8),
FloatingActionButton(
onPressed: () => ref.read(counterProvider.notifier).state--,
child: const Icon(Icons.remove),
),
],
),
);
}
}
// ❌ WRONG — causes a Riverpod assertion error at runtime
onPressed: () {
final count = ref.watch(counterProvider); // NEVER watch in a callback
}
// ✅ CORRECT — read in callbacks, watch in build
onPressed: () {
ref.read(counterProvider.notifier).state++;
}
The rule: watch in build, read in callbacks.
8. StateProvider: Simple Mutable State
StateProvider is the beginner-friendly entry point for mutable state. It holds a single value and exposes a StateController with a .state property you can read and write.
Counter app: setState → Riverpod conversion (side by side)
// ── BEFORE: setState counter ──────────────────────────────────────────────
class _CounterState extends State<CounterPage> {
int _count = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(child: Text('$_count', style: const TextStyle(fontSize: 60))),
floatingActionButton: FloatingActionButton(
onPressed: () => setState(() => _count++),
child: const Icon(Icons.add),
),
);
}
}
// Problems: _count is invisible outside this widget;
// any other screen needs it passed as a constructor arg
// ─────────────────────────────────────────────────────────────────────────
// ── AFTER: Riverpod StateProvider counter ─────────────────────────────────
// Step 1: Declare provider globally — outside any class
final counterProvider = StateProvider<int>((ref) => 0);
// ↑ Any widget anywhere in the app can read or write this
// Step 2: Convert to ConsumerWidget — add WidgetRef ref to build()
class CounterPage extends ConsumerWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Step 3: Watch — rebuilds this widget when count changes
final count = ref.watch(counterProvider);
return Scaffold(
appBar: AppBar(title: const Text('Counter')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('$count', style: const TextStyle(fontSize: 72, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
Text(
'Tap + to increment',
style: TextStyle(color: Colors.grey.shade600),
),
],
),
),
floatingActionButton: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton(
// Step 4: Read — modify state in callback
onPressed: () => ref.read(counterProvider.notifier).state++,
child: const Icon(Icons.add),
),
const SizedBox(width: 8),
FloatingActionButton(
onPressed: () => ref.read(counterProvider.notifier).state--,
child: const Icon(Icons.remove),
),
const SizedBox(width: 8),
FloatingActionButton(
onPressed: () => ref.read(counterProvider.notifier).state = 0,
child: const Icon(Icons.refresh),
),
],
),
);
}
}
// Now ANY screen can access the counter:
class ScoreDisplay extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider); // same provider, different widget, different screen
return Text('Score: $count');
}
}
More StateProvider examples
// Boolean toggle — dark mode, settings switch
final isDarkModeProvider = StateProvider<bool>((ref) => false);
// Selected tab index — bottom navigation bar
final selectedTabProvider = StateProvider<int>((ref) => 0);
// Dropdown / filter selection
final selectedCategoryProvider = StateProvider<String?>((ref) => null);
// Text search query
final searchQueryProvider = StateProvider<String>((ref) => '');
// ── Usage examples ────────────────────────────────────────────────────────
// Dark mode Switch:
final isDark = ref.watch(isDarkModeProvider);
SwitchListTile(
title: const Text('Dark Mode'),
value: isDark,
onChanged: (val) => ref.read(isDarkModeProvider.notifier).state = val,
)
// Bottom nav:
final tab = ref.watch(selectedTabProvider);
NavigationBar(
selectedIndex: tab,
onDestinationSelected: (i) => ref.read(selectedTabProvider.notifier).state = i,
)
// Search field:
TextField(
onChanged: (val) => ref.read(searchQueryProvider.notifier).state = val,
decoration: const InputDecoration(hintText: 'Search...'),
)
// ── When StateProvider is NOT enough ──────────────────────────────────────
// StateProvider holds ONE value. Use NotifierProvider when:
// - You need multiple operations on the state (add, remove, update)
// - You need to combine reads and writes in one method
// - Your state is a List with business logic around it
9. Provider: Services and Derived Values
Lowercase Provider (no “State”) holds read-only values — services, configuration, and values derived from other providers. This is how Riverpod replaces dependency injection frameworks:
// ── Service injection ──────────────────────────────────────────────────────
// Declare once globally — inject anywhere via ref.watch
class ApiService {
Future<List<String>> fetchNotes() async {
await Future.delayed(const Duration(milliseconds: 800)); // simulate network
return ['Note 1', 'Note 2', 'Note 3'];
}
}
final apiServiceProvider = Provider<ApiService>((ref) => ApiService());
// Usage in any widget or provider:
final apiService = ref.watch(apiServiceProvider);
final notes = await apiService.fetchNotes();
// ── Derived / computed values ──────────────────────────────────────────────
// A Provider that computes its value from other providers.
// Automatically recomputes when dependencies change.
final userFirstNameProvider = StateProvider<String>((ref) => 'Alice');
final userLastNameProvider = StateProvider<String>((ref) => 'Smith');
final fullNameProvider = Provider<String>((ref) {
final first = ref.watch(userFirstNameProvider); // ← depends on these
final last = ref.watch(userLastNameProvider);
return '$first $last'; // recomputes whenever either changes
});
// In a widget:
final fullName = ref.watch(fullNameProvider); // → 'Alice Smith'
// Change first name → fullNameProvider auto-recomputes → widget rebuilds
// ── Repository pattern ─────────────────────────────────────────────────────
class NotesRepository {
final ApiService _api;
NotesRepository(this._api);
Future<List<String>> getNotes() => _api.fetchNotes();
}
// Inject the dependency automatically via ref.watch:
final notesRepositoryProvider = Provider<NotesRepository>((ref) {
final api = ref.watch(apiServiceProvider); // ← dependency injected from registry
return NotesRepository(api);
});
10. FutureProvider: Async Data Without FutureBuilder
FutureProvider replaces FutureBuilder entirely. It runs an async function once, handles loading/error/data states automatically, and exposes them through AsyncValue — a sealed class that makes pattern-matching all three states clean and safe:
// ── Step 1: Define the FutureProvider ─────────────────────────────────────
final usernameProvider = FutureProvider<String>((ref) async {
await Future.delayed(const Duration(seconds: 2)); // simulate API call
return 'Alice';
// If this throws, snapshot.hasError becomes true automatically
});
// ── Step 2: Watch it — returns AsyncValue<String> ─────────────────────────
class UserScreen extends ConsumerWidget {
const UserScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncUsername = ref.watch(usernameProvider); // AsyncValue<String>
// ── .when() pattern-matches all three AsyncValue states ───────────────
return asyncUsername.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stackTrace) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.wifi_off, size: 56, color: Colors.grey),
const SizedBox(height: 16),
Text('Something went wrong: $error'),
const SizedBox(height: 16),
ElevatedButton(
// Retry: invalidate forces the provider to re-run
onPressed: () => ref.invalidate(usernameProvider),
child: const Text('Retry'),
),
],
),
),
data: (username) => Center(
child: Text(
'Welcome, $username!',
style: const TextStyle(fontSize: 28),
),
),
);
}
}
// ── FutureProvider with a real API (JSONPlaceholder) ──────────────────────
class Post {
final int id;
final String title;
final String body;
const Post({required this.id, required this.title, required this.body});
factory Post.fromJson(Map<String, dynamic> json) => Post(
id: json['id'] as int,
title: json['title'] as String,
body: json['body'] as String,
);
}
// FutureProvider can depend on other providers (e.g. apiServiceProvider)
final postsProvider = FutureProvider<List<Post>>((ref) async {
final response = await http.get(
Uri.parse('https://jsonplaceholder.typicode.com/posts?_limit=10'),
);
if (response.statusCode == 200) {
final list = jsonDecode(response.body) as List<dynamic>;
return list.map((e) => Post.fromJson(e as Map<String, dynamic>)).toList();
}
throw Exception('HTTP ${response.statusCode}');
});
class PostsScreen extends ConsumerWidget {
const PostsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ref.watch(postsProvider).when(
loading: () => const Scaffold(
body: Center(child: CircularProgressIndicator()),
),
error: (e, _) => Scaffold(
body: Center(child: Text('Error: $e')),
),
data: (posts) => Scaffold(
appBar: AppBar(title: Text('Posts (${posts.length})')),
body: ListView.builder(
itemCount: posts.length,
itemBuilder: (context, index) => ListTile(
title: Text(posts[index].title),
subtitle: Text(posts[index].body, maxLines: 1, overflow: TextOverflow.ellipsis),
),
),
),
);
}
}
// ── ref.invalidate and ref.refresh — force a re-fetch ─────────────────────
// After the user adds a post — re-fetch the list
ElevatedButton(
onPressed: () {
ref.invalidate(postsProvider);
// ↑ marks as stale; re-fetches on next watch
},
child: const Text('Refresh'),
)
// Or immediately re-fetch and wait for it:
FilledButton(
onPressed: () async {
await ref.refresh(postsProvider.future); // triggers re-fetch immediately
},
child: const Text('Reload'),
)
11. Provider Dependencies: Reactive Chains
Providers can watch other providers. When a dependency changes, the dependent provider automatically recomputes — and any widget watching it rebuilds:
// ── Reactive search: filter depends on both query AND source data ──────────
final allNotesProvider = StateProvider<List<String>>((ref) => [
'Buy groceries', 'Call dentist', 'Read Flutter docs',
'Write unit tests', 'Deploy to Play Store',
]);
final searchQueryProvider = StateProvider<String>((ref) => '');
// This provider depends on BOTH above providers.
// It recomputes automatically when either changes.
final filteredNotesProvider = Provider<List<String>>((ref) {
final query = ref.watch(searchQueryProvider); // ← dependency 1
final allNotes = ref.watch(allNotesProvider); // ← dependency 2
if (query.isEmpty) return allNotes;
return allNotes
.where((note) => note.toLowerCase().contains(query.toLowerCase()))
.toList();
});
// ── Widget that uses the reactive chain ───────────────────────────────────
class FilteredNotesWidget extends ConsumerWidget {
const FilteredNotesWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final filtered = ref.watch(filteredNotesProvider);
// When user types in the search field:
// 1. searchQueryProvider updates
// 2. filteredNotesProvider recomputes
// 3. This widget rebuilds with the new filtered list
// allNotesProvider and searchQueryProvider widgets are unaffected
return ListView.builder(
itemCount: filtered.length,
itemBuilder: (_, i) => ListTile(title: Text(filtered[i])),
);
}
}
12. Notes App Upgrade: setState → Riverpod
Here is a complete conversion of a real StatefulWidget notes screen to Riverpod. This is the practical payoff — the moment where the architecture improvement becomes concrete:
// ── BEFORE: StatefulWidget notes screen ───────────────────────────────────
class _NotesScreenState extends State<NotesScreen> {
final List<String> _notes = ['Buy milk', 'Read Flutter docs'];
final _controller = TextEditingController();
void _addNote() {
if (_controller.text.trim().isEmpty) return;
setState(() {
_notes.add(_controller.text.trim());
_controller.clear();
});
}
void _deleteNote(int index) => setState(() => _notes.removeAt(index));
@override
void dispose() { _controller.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Notes (${_notes.length})')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Row(children: [
Expanded(child: TextField(controller: _controller,
decoration: const InputDecoration(hintText: 'New note...'))),
const SizedBox(width: 8),
FilledButton(onPressed: _addNote, child: const Text('Add')),
]),
),
Expanded(
child: ListView.builder(
itemCount: _notes.length,
itemBuilder: (_, i) => ListTile(
title: Text(_notes[i]),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => _deleteNote(i),
),
),
),
),
],
),
);
}
}
// ── AFTER: Riverpod notes screen ───────────────────────────────────────────
// Step 1: Declare provider globally — OUTSIDE any class
final notesProvider = StateProvider<List<String>>((ref) => [
'Buy milk',
'Read Flutter docs',
]);
// Note: For production apps with add/remove/update methods,
// use NotifierProvider (shown briefly below). StateProvider works
// for this beginner example.
// Step 2: ConsumerStatefulWidget because we still need the TextEditingController
class NotesScreen extends ConsumerStatefulWidget {
const NotesScreen({super.key});
@override
ConsumerState<NotesScreen> createState() => _NotesScreenState();
}
class _NotesScreenState extends ConsumerState<NotesScreen> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _addNote() {
final text = _controller.text.trim();
if (text.isEmpty) return;
// Step 3: Update provider state via ref.read in a method
ref.read(notesProvider.notifier).update(
(notes) => [...notes, text], // ← create a NEW list (important!)
);
_controller.clear();
}
void _deleteNote(int index) {
ref.read(notesProvider.notifier).update(
(notes) => [
...notes.sublist(0, index),
...notes.sublist(index + 1),
],
);
}
@override
Widget build(BuildContext context) {
// Step 4: Watch the provider — rebuilds when notes list changes
final notes = ref.watch(notesProvider);
return Scaffold(
appBar: AppBar(title: Text('Notes (${notes.length})')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Row(children: [
Expanded(
child: TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: 'New note...',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _addNote(),
),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _addNote,
icon: const Icon(Icons.add),
label: const Text('Add'),
),
]),
),
Expanded(
child: notes.isEmpty
? const Center(child: Text('No notes yet — add one above!'))
: ListView.builder(
itemCount: notes.length,
itemBuilder: (_, i) => ListTile(
leading: CircleAvatar(child: Text('${i + 1}')),
title: Text(notes[i]),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () => _deleteNote(i),
),
),
),
),
],
),
);
}
}
// ── Key wins over the setState version ────────────────────────────────────
// 1. Any other screen (AppBar badge, statistics screen) can read notesProvider
// 2. The TextField does NOT rebuild when the list changes — only ListView rebuilds
// 3. No callbacks needed to communicate between screens
// 4. notesProvider state survives navigation (if you push to another screen and back)
Brief intro to NotifierProvider (for production apps)
// When your state has complex logic, extract it into a Notifier class.
// This is the production-ready pattern.
class NotesNotifier extends Notifier<List<String>> {
@override
List<String> build() => ['Buy milk', 'Read Flutter docs']; // initial state
void add(String note) {
if (note.trim().isEmpty) return;
state = [...state, note.trim()]; // ← assign NEW list to trigger rebuild
}
void remove(int index) {
state = [
...state.sublist(0, index),
...state.sublist(index + 1),
];
}
void clear() => state = [];
int get count => state.length;
}
final notesNotifierProvider =
NotifierProvider<NotesNotifier, List<String>>(NotesNotifier.new);
// Usage — methods are named and clear:
ref.read(notesNotifierProvider.notifier).add('New note');
ref.read(notesNotifierProvider.notifier).remove(0);
ref.read(notesNotifierProvider.notifier).clear();
13. autoDispose and family (Quick Introduction)
These are modifiers that can be applied to any provider type. Learn what they exist for — the dedicated posts go deeper:
// ── .autoDispose ──────────────────────────────────────────────────────────
// Automatically destroys provider state when no widget is watching it anymore.
// Prevents memory leaks for screen-specific data.
// Without autoDispose: data stays in memory even after screen is popped
final profileProvider = FutureProvider<User>((ref) async { ... });
// With autoDispose: destroyed when the profile screen is closed
final profileProvider = FutureProvider.autoDispose<User>((ref) async { ... });
// Rule: add .autoDispose to any FutureProvider that loads screen-specific data.
// Keep persistent providers (settings, auth state) without it.
// ─────────────────────────────────────────────────────────────────────────
// ── .family ───────────────────────────────────────────────────────────────
// Allows passing a parameter to a provider — like a parameterised factory.
// Without family: can only load ONE user
final userProvider = FutureProvider<User>((ref) async { ... });
// With family: load ANY user by passing their ID
final userByIdProvider = FutureProvider.autoDispose.family<User, String>(
(ref, userId) async {
return await ApiService().getUser(userId); // userId comes from the call site
},
);
// Usage — pass the parameter in parentheses:
final user42 = ref.watch(userByIdProvider('user_42'));
final userAlice = ref.watch(userByIdProvider('alice'));
// Each call with a different ID creates a separate cached provider instance
14. Common Mistakes
| Mistake | What happens | Fix |
|---|---|---|
Using ref.watch inside a callback | Riverpod assertion error at runtime | Use ref.read in callbacks; ref.watch only in build() |
Missing ProviderScope wrapping runApp | ProviderNotFoundException crash at app start | Wrap with const ProviderScope(child: MyApp()) in main() |
Declaring providers inside a class or build() | New provider created on every rebuild — state lost | Always declare providers as top-level final variables |
Using deprecated StateNotifierProvider | Works but teaches the old API — migration debt | Use NotifierProvider for new projects |
Directly mutating a StateProvider list | Does not trigger rebuild — Riverpod compares references | Use .update((list) => [...list, item]) to return a new list |
Accessing .value directly on a FutureProvider | Null crash during loading state | Always use .when() or .maybeWhen() on AsyncValue |
Forgetting .autoDispose on screen-specific providers | Memory leak — state kept alive after screen is gone | Add .autoDispose to any provider that loads data for one screen |
15. Interview Q&A
A:
setState is widget-local — it only rebuilds the widget that calls it. Any other widget that needs the same data must have it passed down through constructors. Riverpod providers are global — declared outside the widget tree, readable by any widget anywhere without prop-drilling. setState is still the right choice for state that truly lives in one widget (a form field, an animation). Riverpod is the right choice when state is shared across widgets or screens.
A:
ref.watch reads the provider AND subscribes — the widget rebuilds when the provider changes. Use only in build(). ref.read reads the provider once without subscribing — no rebuilds. Use in callbacks (onPressed, onChanged). ref.listen subscribes like watch but runs a callback instead of rebuilding the widget — use for side effects like showing a SnackBar or navigating when state changes. The golden rule: watch in build, read in callbacks.
A: Use
StateProvider for simple single-value state where direct assignment is enough — a counter, a boolean toggle, a selected index. Use NotifierProvider when your state needs multiple named methods (add, remove, update), business logic on mutations, or when you want the logic separated from the UI. If you’re writing ref.read(p.notifier).update((list) => ...) in 3+ places, it’s time to move to NotifierProvider.
A:
ProviderScope is the container that stores all Riverpod provider state for the entire app. Every provider’s value lives inside ProviderScope, not in the providers themselves (which are just declarations). It must wrap runApp: runApp(const ProviderScope(child: MyApp())). Without it, accessing any provider throws a ProviderNotFoundException. It can also be nested to override provider values for specific sub-trees — useful for tests.
A: Both were created by Rémi Rousselet — Riverpod is the successor. The key architectural difference: Provider lives in the Flutter widget tree (you must have a valid
BuildContext to access it); Riverpod lives outside the widget tree (accessible anywhere, including non-widget code). Riverpod gives compile-time errors when a provider isn’t found; Provider gives runtime errors. Providers in Riverpod can depend on each other reactively; Provider requires manual setup for this. As of 2026, Riverpod is the official recommendation for new Flutter projects.
16. Related Posts
| Post | Why it’s relevant |
|---|---|
| Flutter Widgets: Stateless vs Stateful | The prerequisite — understand setState before learning what Riverpod replaces it with. |
| Building a Notes App — Part 1 | The exact app upgraded in Section 12 — read Part 1 first to understand the before state. |
| Flutter FutureBuilder Explained | FutureProvider replaces FutureBuilder — read the FutureBuilder post to understand exactly what Riverpod simplifies. |
| Flutter GoRouter Tutorial | GoRouter’s refreshListenable auth guard works perfectly with a Riverpod ChangeNotifier — the two tools pair naturally. |
| Flutter Dark Mode Toggle | The dark mode switch pattern with isDarkModeProvider in this post extends what the dark mode guide teaches — now without the Switch snapping bug. |
