All Guides
    State Management
    State Management
    Riverpod
    Architecture

    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.yaml dependencies:
      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 custom Notifier class (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 (like onPressed) 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's build method.

    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 MyApp widget in a ProviderScope inside the main() 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 BuildContext to make your business logic easier to unit test.
    • Leverage AutoDispose: Use autoDispose configurations (e.g., NotifierProvider.autoDispose) to automatically destroy providers and release resources when they are no longer needed.
    • Use ref.listen for Side Effects: Use ref.listen to handle side effects like showing snackbars or navigating when a state variable changes, rather than putting that logic in the build() method.

    Related Concepts

    Continue mastering state management in Flutter by exploring these guides:


    Summary

    • Riverpod is a compile-safe, global state management solution that does not depend on the widget tree.
    • Wrap your root widget in a ProviderScope to manage application state.
    • Declare providers as global variables to access them from anywhere in the code.
    • Inherit from ConsumerWidget and use ref.watch to listen for state changes, and ref.read to trigger callbacks.

    Next Steps

    1. Previous Guide: Local Storage with Hive
    2. Next Guide: BLoC Pattern Explained
    3. 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