In the previous article we have seen how to build a simple authentication flow with Flutter & Firebase.
As part of this, we created three widgets:
- A
SignInPage
, used to sign in the user - A
HomePage
, used to sign out the user - A
LandingPage
, used to decide which page to show
And these are composed together in the following widget tree:
As the diagram shows, all these widgets access FirebaseAuth
as a global singleton variable:
// SignInPage
await FirebaseAuth.instance.signInAnonymously();
// HomePage
await FirebaseAuth.instance.signOut();
// LandingPage
FirebaseAuth.instance.onAuthStateChanged;
Many 3rd party libraries expose their APIs via singletons, so it is not uncommon to see them used like this.
However, global access via singletons leads to code that is not testable.
And in this case, our widgets become tightly coupled to FirebaseAuth
.
In practice, this means that:
- we cannot write a test to verify that
signInAnonymously()
is called when the sign-in button is pressed. - we can't easily swap
FirebaseAuth
with a different authentication service if we want to.
And we need to address these issues if we want to write clean, testable code.
So in this article we will explore a better alternative to global access.
And I will show how to write an API wrapper for FirebaseAuth
in a follow-up article.
Global Access vs Scoped Access
There is one main problem with global access. And we can use our example code to illustrate this:
class SignInPage extends StatelessWidget {
Future<void> _signInAnonymously() async {
try {
await FirebaseAuth.instance.signInAnonymously();
} catch (e) {
print(e); // TODO: show dialog with error
}
}
...
}
Here, the SignInPage
asks for an instance of FirebaseAuth
directly.
A better approach would be to tell our SignInPage
what it needs, by passing a FirebaseAuth
object to its constructor:
class SignInPage extends StatelessWidget {
SignInPage({@required this.firebaseAuth});
final FirebaseAuth firebaseAuth;
Future<void> _signInAnonymously() async {
try {
await firebaseAuth.signInAnonymously();
} catch (e) {
print(e); // TODO: show dialog with error
}
}
...
}
This approach is called constructor injection.
Note: Widgets in the Flutter SDK are created this way. Arguments are passed to the constructor, and used inside the widget. This guarantees that there are no side effects, because widgets are pure components that only depend on arguments that are passed explicitly.
So, should we always pass dependencies to the constructors of our widgets?
Not so fast.
Flutter uses composition heavily.
This often results in very nested widget trees, and can lead to this scenario:
Here, we have three consumer widgets that require synchronous access to a FirebaseUser
object.
This object is only available inside the StreamBuilder
of the LandingPage
.
In order for our consumer widgets to access FirebaseUser
, we would have to inject it to all intermediate widgets, even if they don't need it directly.
I've been there. Trust me, it's not fun.
To avoid this, you may be tempted to get the user with a call to FirebaseAuth.instance.currentUser()
. However, this is an asynchronous method that returns a Future<FirebaseUser>
. And we shouldn't need to call an async API to get an object that we already retrieved.
So constructor injection doesn't scale well when we need to propagate data down the widget tree.
And it is also very impractical with widgets that require a lot of arguments. Example:
class SomeComplexWidget extends StatelessWidget {
SignInPage({@required this.firebaseAuth, @required this.firestore, @required this.sharedPreferences});
final FirebaseAuth firebaseAuth;
final Firestore firestore;
final SharedPreferences sharedPreferences;
// complex logic here
}
I love a lot of boilerplate code - nobody ever said.
Luckily, there is a solution.
Scoped Access
Scoped access is about making objects available to an entire widget sub-tree.
Flutter already uses this technique. If you ever called Navigator.of(context)
, MediaQuery.of(context)
or Theme.of(context)
, then you have used scoped access.
Under the hood, this is implemented with a special widget called InheritedWidget
.
And while you could build your own widgets based on InheritedWidget
, this can also lead to a lot of boilerplate code.
Well, thanks to the community, there is a better way.
Enter Provider
This library by Remi Rousselet and the Flutter team makes life easier.
In a nutshell, Provider is a dependency injection system for your Flutter apps. We can use it to expose objects of any kind (values, streams, models, BLoCs) to our widgets.
So let's see how to use it.
We can add Provider to our pubspec.yaml
file:
// pubspec.yaml
dependencies:
provider: ^3.0.0
And we can update our example app to use it, by making two changes:
- Add a
Provider<FirebaseAuth>
to the top of our widget tree - Use
Provider.of<FirebaseAuth>(context)
where needed, instead ofFirebaseAuth.instance
.
Note: As an alternative to Provider.of
, you could use Consumer
, which is also part of the Provider package. This page on the official Flutter documentation covers their usage and differences in detail.
The updated widget tree for our example app looks like this:
The top three widgets (MyApp
, Provider
and MaterialApp
), can be composed like this:
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Provider<FirebaseAuth>(
builder: (_) => FirebaseAuth.instance,
child: MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.indigo,
),
home: LandingPage(),
),
);
}
}
Then, we can update our LandingPage
to use Provider
:
class LandingPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
// retrieve firebaseAuth from above in the widget tree
final firebaseAuth = Provider.of<FirebaseAuth>(context);
return StreamBuilder<FirebaseUser>(
stream: firebaseAuth.onAuthStateChanged,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.active) {
FirebaseUser user = snapshot.data;
if (user == null) {
return SignInPage();
}
return HomePage();
} else {
return Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
}
},
);
}
}
And do the same with the SignInPage
and HomePage
to use Provider
:
class SignInPage extends StatelessWidget {
Future<void> _signInAnonymously() async {
try {
// retrieve firebaseAuth from above in the widget tree
final firebaseAuth = Provider.of<FirebaseAuth>(context);
await firebaseAuth.signInAnonymously();
} catch (e) {
print(e); // TODO: show dialog with error
}
}
...
}
class HomePage extends StatelessWidget {
Future<void> _signOut() async {
try {
// retrieve firebaseAuth from above in the widget tree
final firebaseAuth = Provider.of<FirebaseAuth>(context);
await firebaseAuth.signOut();
} catch (e) {
print(e); // TODO: show dialog with error
}
}
...
}
Note about BuildContext: We can retrieve the FirebaseAuth
object because we pass a BuildContext
variable to Provider.of<FirebaseAuth>(context)
. You can think of context
as the position of a widget in the widget tree.
Remember when I said that constructor injection is impractical with deeply nested widget trees?
Well, Provider makes objects accessible to an entire widget sub-tree.
So every time you need to propagate data down the widget tree, Provider should light-up in your brain. 💡
Wrap up
In this article we have learned about scoped access as an alternative to global access.
And we have seen how to use Provider
as a dependency injection system for Flutter.
By the way, this was just a basic introduction.
There are many advanced use cases where we need to make multiple objects accessible to our widgets. Some objects may have inter-dependencies. And we may need different kinds of providers.
Provider supports all these cases. I will write more about it in the future.
For now, you can see how I'm using it in my Reference Authentication Flow with Flutter & Firebase on GitHub:
And for a more in-depth coverage of Provider and how to use it for state management, you can check my Flutter & Firebase course.
Happy coding!