Riverpod Fundamentals
Published April 2026
Introduction
As your Flutter applications scale, you will likely encounter state management challenges. Sharing state variables between distant screens using the standard InheritedWidget system is verbose, and while the Provider package simplifies this, it has limitations. Because Provider depends on the widget tree to look up states (Provider.of(context)), it is susceptible to runtime exceptions if you attempt to look up a state class that is not currently in the widget tree context.
To solve this, the creator of the Provider package wrote Riverpod. Riverpod is a complete rewrite of Provider that is designed to be compile-safe. It does not rely on the widget tree to manage or lookup providers.
In Riverpod, providers are declared as global constants, making them accessible from anywhere in your application. The package guarantees compile-time safety (preventing ProviderNotFoundException crashes), supports multiple providers of the same data type, and makes testing state logic simple. In this guide, you will learn Riverpod's design patterns, configure notifier classes, and build a compiler-ready task planner.
Prerequisites
Before implementing Riverpod, ensure you have:
- Read our Managing State with Provider guide.
- A solid grasp of Dart classes and state flow models.
- Added Riverpod to your project's
pubspec.yamldependencies:dependencies: flutter_riverpod: ^2.4.9
Core Explanation: The Compile-Safe State Architecture
Riverpod manages application state outside of the widget tree, resolving references at compile time.
Providers Scope Context Widget Tree Viewport
┌──────────────────────────┐ ┌────────────────────────────┐
│ ProviderScope (Root) │ │ ConsumerWidget │
│ │ │ │
│ Global Const Providers │ │ WidgetRef ref │
│ ├── taskListNotifier ├───────►│ ├── ref.watch() (Listen) │
│ └── themeProvider │ │ └── ref.read() (Action) │
└──────────────────────────┘ └────────────────────────────┘
1. ProviderScope (Global Containers)
For Riverpod to track states, you must wrap your root application in a ProviderScope widget. This scope widget stores the state of all providers in your application.
2. Declaring Global Providers
Unlike the Provider package, Riverpod providers are declared as global variables. This means you do not need a BuildContext to access them.
Provider: The simplest provider type, used to share read-only values (like configuration parameters or API clients).StateProvider: Used to manage simple, mutable variables (like a selected index or a theme boolean).NotifierProvider: Used to manage complex, stateful objects. It connects a customNotifierclass (which manages state mutations) to your UI.
3. Reading Providers: ConsumerWidget & WidgetRef
To access providers inside your widgets, you inherit from ConsumerWidget instead of StatelessWidget. This widget's build method provides a WidgetRef parameter, which acts as your handle to read and listen to providers:
ref.watch(provider): Listens for state changes. Rebuilds the widget whenever the provider's state updates.ref.read(provider): Reads a provider's value once without listening for changes. Ideal for button callbacks (likeonPressed) to trigger actions without causing unnecessary rebuilds.
Practical Example: A Riverpod Task Planner
Let's build a clean, compiler-ready Task Planner app using modern Riverpod Notifier classes.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
runApp(
// 1. Every Riverpod application must be wrapped in a ProviderScope root.
const ProviderScope(
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Riverpod Task Planner',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const TaskPlannerScreen(),
);
}
}
// ==========================================
// DATA MODEL CLASS
// ==========================================
class RiverpodTask {
final String id;
final String title;
final bool isCompleted;
RiverpodTask({
required this.id,
required this.title,
this.isCompleted = false,
});
// Helper method to clone instance with updated properties
RiverpodTask copyWith({String? id, String? title, bool? isCompleted}) {
return RiverpodTask(
id: id ?? this.id,
title: title ?? this.title,
isCompleted: isCompleted ?? this.isCompleted,
);
}
}
// ==========================================
// NOTIFIER (State Logic Controller)
// ==========================================
class TaskListNotifier extends Notifier<List<RiverpodTask>> {
// 2. Build is called when the provider initializes. Must return initial state.
@override
List<RiverpodTask> build() {
return []; // Start with an empty list
}
void addTask(String title) {
final newTask = RiverpodTask(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: title,
);
// In Riverpod, state is immutable. We must create a new list reference.
state = [...state, newTask];
}
void toggleTask(String id) {
state = [
for (final task in state)
if (task.id == id)
task.copyWith(isCompleted: !task.isCompleted)
else
task,
];
}
void deleteTask(String id) {
state = state.where((task) => task.id != id).toList();
}
}
// ==========================================
// GLOBAL PROVIDER DECLARATION
// ==========================================
// We declare the provider as a global constant.
// This links our TaskListNotifier class to its list of tasks.
final taskListProvider = NotifierProvider<TaskListNotifier, List<RiverpodTask>>(() {
return TaskListNotifier();
});
// ==========================================
// UI SCREEN (ConsumerWidget)
// ==========================================
class TaskPlannerScreen extends ConsumerWidget {
const TaskPlannerScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// 3. We use ref.watch to listen to the provider.
// This widget will rebuild automatically whenever tasks are modified.
final tasks = ref.watch(taskListProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Riverpod Task Planner'),
centerTitle: true,
),
body: tasks.isEmpty
? const Center(child: Text('No tasks yet. Click "+" to add one.'))
: ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: tasks.length,
itemBuilder: (context, index) {
final task = tasks[index];
return Card(
child: ListTile(
title: Text(
task.title,
style: TextStyle(
decoration: task.isCompleted
? TextDecoration.lineThrough
: TextDecoration.none,
),
),
leading: Checkbox(
value: task.isCompleted,
onChanged: (value) {
// 4. We use ref.read to access the notifier and call functions.
ref.read(taskListProvider.notifier).toggleTask(task.id);
},
),
trailing: IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () {
ref.read(taskListProvider.notifier).deleteTask(task.id);
},
),
),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {
final timeKey = DateTime.now().millisecondsSinceEpoch.toString();
ref.read(taskListProvider.notifier).addTask('Task #$timeKey');
},
child: const Icon(Icons.add),
),
);
}
}
Common Mistakes
1. Using ref.read Inside the build() Method
Using ref.read to render data inside your build method prevents the widget from updating when state changes.
// BAD PRACTICE
@override
Widget build(BuildContext context, WidgetRef ref) {
// Will read the list exactly once, but the UI will never update on changes
final tasks = ref.read(taskListProvider);
return ListView(...);
}
- The Fix: Always use
ref.watch(provider)to subscribe to state updates inside your widget'sbuildmethod.
2. Missing the ProviderScope Root
Failing to wrap your application root in a ProviderScope widget will crash your app on startup with a ProviderScope not found exception.
- The Fix: Wrap your root
MyAppwidget in aProviderScopeinside themain()function.
3. Mutating State Directly
Attempting to mutate list contents directly (like state.add(task)) in a Notifier will not trigger a rebuild. Riverpod uses Dart's object reference equality check to detect changes.
- The Fix: Always create a new reference when updating state variables (e.g.
state = [...state, newTask]).
Best Practices
- Avoid Context Dependencies: Write state classes that are decoupled from
BuildContextto make your business logic easier to unit test. - Leverage AutoDispose: Use
autoDisposeconfigurations (e.g.,NotifierProvider.autoDispose) to automatically destroy providers and release resources when they are no longer needed. - Use
ref.listenfor Side Effects: Useref.listento handle side effects like showing snackbars or navigating when a state variable changes, rather than putting that logic in thebuild()method.
Related Concepts
Continue mastering state management in Flutter by exploring these guides:
- Understanding Widgets in Flutter: Learn the difference between layouts and the rendering layer.
- Managing State with Provider: Review older state management architectures.
- BLoC Pattern Explained: Explore event-driven architecture using the BLoC pattern.
Summary
- Riverpod is a compile-safe, global state management solution that does not depend on the widget tree.
- Wrap your root widget in a
ProviderScopeto manage application state. - Declare providers as global variables to access them from anywhere in the code.
- Inherit from
ConsumerWidgetand useref.watchto listen for state changes, andref.readto trigger callbacks.
Next Steps
- Previous Guide: Local Storage with Hive
- Next Guide: BLoC Pattern Explained
- Related Guide: Managing State with Provider
Ready to take the next step?
Start our free Flutter course and build real apps today.
Start Learning Now