Riverpods Ref Observers: The Secret Sauce to Effortless State Management in Flutter

Riverpods Ref Observers: The Secret Sauce to Effortless State Management in Flutter

ยท

4 min read

Prerequisites:

  • It's essential to be familiar with the basics of flutter state management with Riverpod to get a grasp of this article;

  • Basic Knowledge of the Riverpod Providers.

Overview:

Riverpod simplifies state management and optimizes reactive programming in Flutter, solving the complexities of implementing common use cases like swipe-to-refresh. Riverpod builds on the Provider library but provides some optimizations to make reactive programming easy in Flutter. A basic Riverpod Provider implementation looks like this:

final countProvider = Provider<int>((ref) {
  return 0;
});

The above code snippet shows a provider that returns an integer value. This value can best be accessed from other parts of the code using the Ref class instance. For example, the value returned by the counterProvider above can be accessed using the ref.watch, ref.read or ref.listen methods as seen below:

ref.watch(countProvider);

However, while all these methods have access to the Provider's state, they perform differently in the context of an application. This article gives a detailed explanation of how to use each of these ref methods.

ref .watch

This method is used for listening to state changes from the Provider, and it rebuilds the UI accordingly. It should be declared at the top level of a widget's build method. For example, consider a use case where clicking a button increments a counter and immediately updates the counter state value on the UI.

final counterProvider = StateProvider<int>((ref) => 0)

class CounterApp extends ConsumerWidget{
  const CounterApp({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(counterProvider);
    return Scaffold(
      body: Center(
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.center,
          children: [
            Text(state.toString()),
            ElevatedButton(
                onPressed: () {
                  ref.read(counterProvider.notifier).state += 1;
                },
                child: const Text('Click'))
          ],
        ),
      ),
    );
  }
}

The unique feature of Providers when using ref.watch is that even when the Provider is re-evaluated, the widgets that listen to it will not be rebuilt unless there's a change to the state value. This method can also trigger events like the onPressed function, mainly when the event depends on the Provider's state.

N-B: Declaring the ref.watch directly in an asynchronous function like the onPressed function can cause unintended behaviour and potential performance issues. This includes unnecessary evaluation of the Provider when there are no changes to the state, leading to rendering the UI when not needed. This re-rendering of the UI can lead to a waste of resources and memory leaks.

ref.read

This method is commonly used to trigger events when accessing the Provider without listening to the value emitted by the state. The method is used mainly for events like onClick and onTap. Unlike the ref.watch, this method does not reflect changes made to the Provider's state. To implement this following the counter app example, add the code provided below to the onPressed block of the ElevatedButton:

//This increments the value of the counter state.
ref.read(counterProvider.notifier).state += 1;

However, there is a downside to using ref.read; it is not scalable, and this is because one needs to know if there will be a need to listen to changes from the Provider in the future which ref.read doesn't, and if that happens, you would have to change every instance of it in your code, which can be error-prone. The solution is to use the ref.watch to achieve the same goal and solve the scalability issue. However, it should be declared outside the onPressed method, as stated earlier. Instead of the ref.read in the onPressed function above, do this:

@override
Widget build(BuildContext context, WidgetRef ref) {
  StateController<int> counter = ref.watch(counterProvider.notifier);
  final state = ref.watch(counterProvider);
    return Scaffold(
      body: Center(
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.center,
          children: [
            Text(state.toString()),
            ElevatedButton(
                onPressed: () {
                  counter.state += 1;
                },
                child: const Text('Click'))
          ],
        ),
      ),
    );
  }
}

ref.listen

This approach is similar to the ref.watch method for listening to state changes, however instead of rebuilding the UI after a state change, a specified function or event is executed, such as an error notification. ref.list() takes in two parameters: the Provider and a lambda function that holds the old and new state values that the intended operation can use. The intended function is executed in the lambda block. For instance, when the state of a Provider changes, a SnackBar can be triggered to signal the change. Refer to the sample code below:


@override
Widget build(BuildContext context, WidgetRef ref){
    ref.listen<int>(counterProvider, (int? previousCount, int newCount) {
      ScaffoldMessenger.of(context).showSnackBar(
       SnackBar(
          content: Text('$newCount is the new count'),
        ),
      );
    });
}

Similar to ref.watch , ref.listen should not be called in an asynchronous function but can be called from the build method, initState block or other providers.

Conclusion

After learning about the various ways of accessing providers in Riverpod, you are now equipped to make informed decisions about which method to use. To become more comfortable with the different use cases, I recommend experimenting with them. For further guidance and knowledge, I suggest consulting the official Riverpod documentation. Keep up the excellent work!

ย