Skip to content

Riverpod ​

State Management ​

I followed NetNinja's Riverpod Playlist

Providers ​

Providers are the central piece of Riverpod where state is defined and the access to this state is defined for other widgets.

Types:

  • Read-only providers: only provides readonly access
  • Notifier providers: provides access to change the state and notify other widgets
  • Future providers: For async data

Example:

dart
const List<Product> allProducts = [
    Product(id: '1', title: 'Groovy Shorts', price: 12, image: 'assets/products/shorts.png'),
  Product(id: '2', title: 'Karati Kit', price: 34, image: 'assets/products/karati.png'),
];

final productsProvider = Provider((ref){
  return allProducts;
});

final reducedProductsProvider = Provider((ref) {
  return allProducts.where((element) => element.price < 50).toList();
});

ProviderScope ​

The providers can be used only in widgets below the ProviderScope. As such, the whole app is recommended to be wrapped in ProviderScope.

ConsumerWidget ​

Stateless widgets should be transformed into ConsumerWidget to access the providers. The providers are accessed using the WidgetRef property of the build method. Refs can either be read or watched.

Example:

dart
class HomeScreen extends ConsumerWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final allProducts = ref.watch(productsProvider);
								// ^^ imported from providers/products_provider.dart

    return Scaffold(
      body: GridView.builder(
          itemCount: allProducts.length,
			// snip
          itemBuilder:(context, index) {
            return Container(
              padding: const EdgeInsets.all(20),
              color: Colors.blueGrey.withOpacity(0.05),
              child: Column(
                children: [
                  Image.asset(allProducts[index].image, width: 60, height: 60,),
                  Text(allProducts[index].title),
                  Text("\$${allProducts[index].price}"),
                ],
              ),
            );
          },
        ),
    );
  }
}

ConsumerStatefulWidget ​

Stateful widgets should be converted to ConsumerStatefulWidget and the state should be converted to ConsumerState. The ref object is provided inside the state and need not be passed in the build method.

dart
class CartScreen extends ConsumerStatefulWidget {
  const CartScreen({super.key});

  @override
  ConsumerState<CartScreen> createState() => _CartScreenState();
}

class _CartScreenState extends ConsumerState<CartScreen> {
  bool showCoupon = true;

  @override
  Widget build(BuildContext context) {
    final cartProducts = ref.watch(reducedProductsProvider);
    // snip
  }
}

Generated Providers ​

Providers can be generated using the riverpod_annotations and riverpod_generator packages. The @riverpod annotation tells the generator to generate a provider for the method. The provider will be named the <methodName>Provider.

dart
// part 'file_name.g.dart'
part 'product_provider.g.dart';

const List<Product> allProducts = [
    Product(id: '1', title: 'Groovy Shorts', price: 12, image: 'assets/products/shorts.png'),
  Product(id: '2', title: 'Karati Kit', price: 34, image: 'assets/products/karati.png'),
];

@riverpod
List<Product> products(ref) {
  return allProducts;
}

@riverpod
List reducedProducts(ref) {
  return allProducts.where((element) => element.price < 50).toList();
}

Notifier Provider ​

Change state and notify widgets to rebuild with NotifierProvider.

dart
class CartNotifier extends Notifier<Set<Product>> {
  
  // initial state
  @override
  Set<Product> build() {
  return const {};
  }

  // methods to change state
  void addProduct(Product product) {
    if(!state.contains(product)){
      state = {...state, product};
    }
  }
  
  void removeProduct(Product product) {
    if(state.contains(product)){
      state = state.where((element) => element.id != product.id).toSet();
    }
  }
}

final cartNotifierProvider = NotifierProvider<CartNotifier, Set<Product>>(() {
  return CartNotifier();
});

Using the provider: To read, it is the same as the normal provider. But to invoke the methods, we use ref.read.

dart
TextButton(
 onPressed: () {
       ref.read(cartNotifierProvider.notifier).removeProduct(allProducts[index]);
        },
     child: const Text('Remove'),
        ),
        if (!cartProducts.contains(allProducts[index]))
TextButton(
 onPressed: () {
       ref.read(cartNotifierProvider.notifier).addProduct(allProducts[index]);
        },
     child: const Text('Add to Cart'),
        ),

Generated Notifier Provider ​

To generate the notifier provider from the Notifier:

  • Replace the parent class with _$<notifierName>
  • Add @riverpod annotation
  • Add part <filename>.g.dart It automatically generates a notifier provider with the name <notifierName>Provider
dart
part 'cart_provider.g.dart';

@riverpod
class CartNotifier extends _$CartNotifier {
  
  // initial state
  @override
  Set<Product> build() {
  return const {};
  }

  // methods to change state
  void addProduct(Product product) {
    if(!state.contains(product)){
      state = {...state, product};
    }
  }
  
  void removeProduct(Product product) {
    if(state.contains(product)){
      state = state.where((element) => element.id != product.id).toSet();
    }
  }
}

Using Other Providers in Another Provider (Dependent Providers) ​

To use the state of other providers in another provider, we can use the ref object. eg, if we wanted to use the cart's state in a cartTotalProvider, we can get the cartProvider and use it.

dart
@riverpod
double cartTotal(ref) {
  final Set<Product> cartProducts = ref.watch(cartNotifierProvider);
  double sum = 0;
  for (final Product product in cartProducts) {
    sum += product.price;
  }

  return sum;
}

Business Logic ​

Performing a network request is usually what we call "business logic". In Riverpod, business logic is placed inside "providers".
A provider is a super-powered function. They behave like normal functions, with the added benefits of:

  • Being cached
  • Offering default error/loading handling
  • Being listenable
  • Automatically re-executing when some data changes

GET ​

Futures are wrapped in AsyncValue and are cached.

  • The network request will not be executed until the UI reads the provider at least once.
  • Subsequent reads will not re-execute the network request, but instead return the previously fetched activity.
  • If the UI stops using this provider, the cache will be destroyed. Then, if the UI ever uses the provider again, that a new network request will be made.
  • Providers natively handle errors and need not be catched. If the network request or if the JSON parsing throws, the error will be caught by Riverpod. Then, the UI will automatically have the necessary information to render an error page.
dart
 final AsyncValue<Activity> activity = ref.watch(activityProvider);
//snip
      child: switch (activity) {
        AsyncData(:final value) => Text('Activity: ${value.activity}'),
        AsyncError() => const Text('Oops, something unexpected happened'),
        _ => const CircularProgressIndicator(),
      },

POST ​

Providers are normally used for Get calls, but can also expose POST methods.

dart
@riverpod
class TodoList extends _$TodoList {
  @override
  Future<List<Todo>> build() async => [/* ... */];

  Future<void> addTodo(Todo todo) async {
    await http.post(
      Uri.https('your_api.com', '/todos'),
      // We serialize our Todo object and POST it to the server.
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode(todo.toJson()),
    );
  }
}

Usage:

dart
class Example extends ConsumerWidget {
  const Example({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return ElevatedButton(
      onPressed: () {
        // Using "ref.read" combined with "myProvider.notifier", we can
        // obtain the class instance of our notifier. This enables us
        // to call the "addTodo" method.
        ref
            .read(todoListProvider.notifier)
            .addTodo(Todo(description: 'This is a new todo'));
      },
      child: const Text('Add todo'),
    );
  }
}

Updating Local State: ​

  1. Use ref.invalidateSelf() to refresh the provider after the post call:
dart
  Future<void> addTodo(Todo todo) async {
    // We don't care about the API response
    await http.post(
      Uri.https('your_api.com', '/todos'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode(todo.toJson()),
    );

    // Once the post request is done, we can mark the local cache as dirty.
    // This will cause "build" on our notifier to asynchronously be called again,
    // and will notify listeners when doing so.
    ref.invalidateSelf();

    // (Optional) We can then wait for the new state to be computed.
    // This ensures "addTodo" does not complete until the new state is available.
    await future;
  }
  1. Update local cache by replicating backend code/locally update state:
dart
  Future<void> addTodo(Todo todo) async {
    // We don't care about the API response
    await http.post(
      Uri.https('your_api.com', '/todos'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode(todo.toJson()),
    );

    // We can then manually update the local cache. For this, we'll need to
    // obtain the previous state.
    // Caution: The previous state may still be loading or in error state.
    // A graceful way of handling this would be to read `this.future` instead
    // of `this.state`, which would enable awaiting the loading state, and
    // throw an error if the state is in error state.
    final previousState = await future;

    // We can then update the state, by creating a new state object.
    // This will notify all listeners.
    state = AsyncData([...previousState, todo]);
  }

Streams: ​

Riverpod naturally supports Stream objects. Like with Futures, the object will be converted to an AsyncValue:

dart
@riverpod
Stream<int> streamExample(Ref ref) async* {
  // Every 1 second, yield a number from 0 to 41.
  // This could be replaced with a Stream from Firestore or GraphQL or anything else.
  for (var i = 0; i < 42; i++) {
    yield i;
    await Future<void>.delayed(const Duration(seconds: 1));
  }
}

class Consumer extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // The stream is listened to and converted to an AsyncValue.
    AsyncValue<int> value = ref.watch(streamExampleProvider);

    // We can use the AsyncValue to handle loading/error states and show the data.
    return switch (value) {
      AsyncValue(:final error?) => Text('Error: $error'),
      AsyncValue(:final valueOrNull?) => Text('$valueOrNull'),
      _ => const CircularProgressIndicator(),
    };
  }
}

Using other providers in another provider ​

To use other providers in another provider, we use either ref.watch or ref.read mostly. ref.listen is not recommended.

  1. using watch would automatically refresh the state of all providers it depends on when it's state changes.
  2. using read will not refresh the state will not refresh the state.