4  comments

Riverpod is the response to all the insufficiencies of other dependency injection and state management packages for Dart & Flutter apps.  It's quick to get familiar with, maintainable, testable and it's much less error-prone than the other solutions out there. Let's take a look at the core principles of Riverpod.

How is Riverpod any different?

There are multiple ways to provide dependencies around your Flutter app. Service locators, such as get_it, are great but they aren't directional, for lack of a better word. This means you can access objects in whatever order you want and you're not necessarily guaranteed that all of them are properly initialized unless you're careful. This can result in major headaches especially on large projects with huge amounts of dependencies.

Another solution is to use the Provider package which is a well-known and widely accepted package for providing objects around your Flutter apps. In this tutorial I assume that you've had a chance to use it before. It's great, it's directional but it also has many drawbacks. 

Provider is not perfect

For one, Provider is dependent on Flutter since you're using widgets to provide objects down the widget tree. I've never been very comfortable with mixing the UI code with dependency injection. Provider's usage of widgets also goes hand in hand with all the nesting it creates. Imagine we have MySecondClass that depends on MyFirstClass.

main.dart

class MySecondClass {
  final MyFirstClass myFirstClass;
  MySecondClass(this.myFirstClass);
}

Although I'm not going to explain the Provider package in this tutorial, take a look at the horribly nested UI code needed to properly construct an instance of MySecondClass.

main.dart

class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Provider(
      create: (context) => MyFirstClass(),
      child: ProxyProvider<MyFirstClass, MySecondClass>(
        update: (context, firstClass, previous) => MySecondClass(firstClass),
        child: MyVisibleWidget(),
      ),
    );
  }
}

Secondly, Provider relies only on types to resolve the requested object. If you provide two objects of the same type, you can get only the one closer to the call site.

main.dart

class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Provider(
      create: (context) => 'A String far away.',
      child: Provider(
        create: (context) => 'A String that is close.',
        builder: (context, child) {
          // Displays 'A String that is close.'
          // There's no way to obtain 'A String far away.'
          return Text(Provider.of<String>(context));
        },
      ),
    );
  }
}

Lastly, if you try to access a type which is not provided at all, you're going to get an error only at run-time. This is not ideal since we should always strive to catch as many errors as possible at compile-time.

The basics of Riverpod

Riverpod combines the best things from service locators and Provider which results in a package that is just pleasurable to use . It's compile-time safe, doesn't depend on Flutter, it's still directional and the boilerplate is minimal. Sounds too good to be true? Well, this old adage just doesn't apply in some cases and Riverpod is thankfully one of them.

The first step is to add the package as a dependency to your Flutter project. In this tutorial, we're going to use flutter_riverpod which is the way to go if you want to use pure Flutter.

pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^0.12.1

If you're using flutter_hooks in your project, you will probably want to also use hooks_riverpod. Lastly, there's the pure Dart riverpod package in case you're developing for something else than the Flutter framework.

Riverpod's Providers aren't placed into the widget tree. They're instead put into global variables which can be inside any file you want. As long they're accessible, you're fine.

main.dart

final greetingProvider = Provider((ref) => 'Hello Riverpod!');

This simplest Provider can expose a read-only value. There are many more types of  Providers for working with Futures, Streams, ChangeNotifiers, StateNotifiers and more.

The ref parameter is of type ProviderReference. As you'll see later on, it's mostly used to resolve dependencies between providers.

While the Provider object is globally accessible, this does not mean that the provided object (in this case a string "Hello Riverpod!") is itself global. Much like with a global function, you can call it from anywhere but the returned value may also be scoped locally. Consider the following code:

function_analogy.dart

String globalFunction() {
  return 'some value';
}

class MyClass {
  void _classMethod() {
    final valueLocalToThisMethod = globalFunction();
  }
}

Where are the widgets!?

Although it's great news that Riverpod's Providers are Flutter-independent, we still need to use the value provided by a Provider object from the widget tree - this is Flutter, after all.

The Riverpod package comes with just a single InheritedWidget that needs to be placed above the whole widget tree called ProviderScope. It's responsible for holding something called a ProviderContainer which is in turn responsible for storing the state of individual Provider objects.

main.dart

void main() {
  runApp(
    ProviderScope(
      child: MyApp(),
    ),
  );
}
For the most part, having a single ProviderScope wrapping the whole widget tree is enough. You can have multiple ones if you want to override certain providers for a part of the widget tree.

Watching a provider

How do we then obtain the string from the greetingProvider so that we can display it using a Text widget? There are actually two ways to do it. Let's showcase both based on the same piece of standard Flutter code. We want to get the provided string into the highlighted widget.

main.dart

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Riverpod Tutorial',
      home: Scaffold(
        appBar: AppBar(
          title: Text('Riverpod Tutorial'),
        ),
        body: Center(
          child: Text('greeting goes here'),
        ),
      ),
    );
  }
}

The first way is to change the superclass of our widget to ConsumerWidget which comes from the flutter_riverpod package. This adds a ScopedReader function into the widget's build method. Since we use the ScopedReader to watch the provider and rebuild the widget if any change occurs, the convention is to give this parameter a name "watch".

main.dart

class MyApp extends ConsumerWidget {
  @override
  Widget build(BuildContext context, ScopedReader watch) {
    // Gets the string from the provider and causes
    // the widget to rebuild when the value changes.
    final greeting = watch(greetingProvider);

    return MaterialApp(
      title: 'Riverpod Tutorial',
      home: Scaffold(
        appBar: AppBar(
          title: Text('Riverpod Tutorial'),
        ),
        body: Center(
          child: Text(greeting),
        ),
      ),
    );
  }
}

The other way of getting the value out of a provider is useful if you want to quickly optimize your widget rebuilds. It's not the end of the world in our case, but we're still rebuilding the whole MaterialApp while, in fact, we only need to rebuild the affected Text widget all the way down the widget tree. This is what the Consumer widget is for.

main.dart

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Riverpod Tutorial',
      home: Scaffold(
        appBar: AppBar(
          title: Text('Riverpod Tutorial'),
        ),
        body: Center(
          child: Consumer(
            builder: (context, watch, child) {
              final greeting = watch(greetingProvider);
              return Text(greeting);
            },
          ),
        ),
      ),
    );
  }
}

Reading a provider

Sometimes, it may be impossible to call watch because you're not inside of the build  method. For example, you may want to perform an action when a button is pressed. That's when you can call context.read(). Let's showcase it on another type of a provider - ChangeNotifierProvider. Consider this code:

main.dart

class IncrementNotifier extends ChangeNotifier {
  int _value = 0;
  int get value => _value;

  void increment() {
    _value += 1;
    notifyListeners();
  }
}

final incrementProvider = ChangeNotifierProvider((ref) => IncrementNotifier());

As you've probably seen a thousand times already, we're going to create the classic counter app. A Text widget will watch the provider and get automatically rebuilt if a change occurs and the FloatingActionButton will only read the provider to call the increment() method on it.

main.dart

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Riverpod Tutorial',
      home: Scaffold(
        body: Center(
          child: Consumer(
            builder: (context, watch, child) {
              final incrementNotifier = watch(incrementProvider);
              return Text(incrementNotifier.value.toString());
            },
          ),
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: () {
            context.read(incrementProvider).increment();
          },
          child: Icon(Icons.add),
        ),
      ),
    );
  }
}

Stuff you can't do with the "original" Provider

As you could've seen by now, Riverpod's providers objects don't rely on types for resolving their values. This is in contrast with the "original" Provider package. This is awesome because you can have multiple providers of the same type without any issue. 

example.dart

final firstStringProvider = Provider((ref) => 'First');
final secondStringProvider = Provider((ref) => 'Second');

// Somewhere inside a ConsumerWidget
final first = watch(firstStringProvider);
final second = watch(secondStringProvider);

Another conclusion that logically flows out of Riverpod not relying on types is that you cannot possibly try to get a value which is unavailable. You can only watch or read a provider variable that you can access and as long as you can access a provider, its value has to be available, right?

Dependency between providers

Any real-world app has dependencies between classes. For example, you can have a ChangeNotifier that depends on a Repository which in turn depends on a HttpClient. Handling such dependencies with Riverpod is simple and readable.

To keep our example simple, let's just have a FutureProvider depend directly on a simple FakeHttpClient. Getting hold of another provider inside a provider's function is done by calling read on the ProviderReference parameter which is always passed in. If you depend on a provider whose value can change, you can also call watch.

This is the first time we deal with a FutureProvider but I don't think its creation requires much explanation. It looks almost exactly like the most basic Provider but the function that you pass in returns a Future. Just like with aProvider the creation function of a FutureProvider runs as soon as we obtain it from a widget for the first time.

main.dart

class FakeHttpClient {
  Future<String> get(String url) async {
    await Future.delayed(const Duration(seconds: 1));
    return 'Response from $url';
  }
}

final fakeHttpClientProvider = Provider((ref) => FakeHttpClient());
final responseProvider = FutureProvider<String>((ref) async {
  final httpClient = ref.read(fakeHttpClientProvider);
  return httpClient.get('https://resocoder.com');
});

Using values from a FutureProvider from the UI is absolutely delightful. Gone are the days of clumsy FutureBuilders! Riverpod makes building widgets based on a Future easy. It uses freezed unions behind the scenes to break down a Future object into an AsyncValue union which we can easily map to different widgets.

main.dart

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Riverpod Tutorial',
      home: Scaffold(
        body: Center(
          child: Consumer(
            builder: (context, watch, child) {
              final responseAsyncValue = watch(responseProvider);
              return responseAsyncValue.map(
                data: (_) => Text(_.value),
                loading: (_) => CircularProgressIndicator(),
                error: (_) => Text(
                  _.error.toString(),
                  style: TextStyle(color: Colors.red),
                ),
              );
            },
          ),
        ),
      ),
    );
  }
}

Passing arguments to providers

What if we wanted to pass a user-defined URL to the responseProvider? That's what provider families are for! By changing the responseProvider to the following...

main.dart

final responseProvider =
    FutureProvider.family<String, String>((ref, url) async {
  final httpClient = ref.read(fakeHttpClientProvider);
  return httpClient.get(url);
});

...we can now pass the request URL from the UI. Sure, there's no actual user input to keep things simple but you can try changing the hard-coded URL string and you'll see that the FutureProvider reruns its creation function each time you change the passed in string.

main.dart

final responseAsyncValue = watch(responseProvider('https://resocoder.com'));

Automatically disposing of state

Here's an interesting observation - try changing the inputted URL to A then to B and lastly back to A. What happened? When you changed the value back to A, there was no CircularProgressIndicator displayed which means that the value was readily present in the FutureProvider and there was no need to perform yet another fake get request.

This caching of a provider's state is great but sometimes you may want to perform requests even to the same URL over and over again. That's what the autoDispose modifier is for.

main.dart

final responseProvider =
    FutureProvider.autoDispose.family<String, String>((ref, url) async {
  final httpClient = ref.read(fakeHttpClientProvider);
  return httpClient.get(url);
});

This modifier will dispose of the provider's state as soon as the provider is not used. In our case, this happens if we change the argument passed into the provider family. However, autoDispose is useful even if you're not using a family modifier. In such case, disposal will be the started when the ConsumerWidget that watches a provider is disposed.

Conclusion

You've just learned the essence of the Riverpod package. We've gone through the very basics all the way to provider modifiers. Of course, we can go more in-depth because this package has a lot more to offer. If you don't want to miss the upcoming tutorials about building more advanced apps with Riverpod, sign up to the weekly Flutter newsletter below.

About the author 

Matt Rešetár

Matt is an app developer with a knack for teaching others. Working as a Flutter freelancer and most importantly developer educator, he doesn't have a lot of free time 😅 Yet he still manages to squeeze in tough workouts 💪

You may also like

Flutter Integration Test Tutorial + Firebase Test Lab & Codemagic

Flutter Bloc & Cubit Tutorial

  • I heard this can replace get_it, however I still don’t get clearly how to use it to inject/locate repository and other things.

    So if I have these:
    – Repository Class which implements IRepository
    – FakeHttpClient Class which implement IHttpClient
    – FirebaseLogger which implement ILogger
    – Repository that use IHttpClient and ILogger
    – Provider Class that extend ChangeNotifier and use IRepository

    then I have to create:

    final httpClientProvider = Provider((ref) => FakeHttpClient());

    final loggerProvider = Provider((ref) => FirebaseLogger());

    final repositoryProvider = Provider((ref) => Repository(ref.read(httpClientProvider)));

    final provider = ChangeNotifierProvider(
    (ref) => Provider(
    ref.read(loggerProvider),
    ref.read(approvalRepository),
    ));

    Is this right?

  • I have an abstract class LectureRepository implemented by LectureRepositoryImpl. LectureRepositoryImpl initialises SharedPreferences sharedPreferences with

    LectureLocalDataSourceImpl({@required this.sharedPreferences})
    Now I want to use Riverpod to access the repository so I do:

    FutureProvider(
    (ref) async {
    return LectureLocalDataSourceImpl(sharedPreferences: await SharedPreferences.getInstance());
    },
    );
    but now in lectureRepositoryProvider, wenn I read the lectureLocalDataRepositoryProvider I get an AsyncValue in the value localDataSource, which I cannot assign here:

    final lectureRepositoryProvider = FutureProvider((ref) async {
    final localDataSource = ref.read(lectureLocalDataRepositoryProvider);
    return LectureRepositoryImpl(localDataSource: localDataSource);
    });
    How should I handle the AsyncValue?

  • {"email":"Email address invalid","url":"Website address invalid","required":"Required field missing"}
    >