Provider-based Dependency Injection in Flutter

Part 6 in my “Flutter from a complete beginner” series.

Tiger Asks...
18 min readMar 12, 2024

Tiger asks … what’s “the flutter way” to do DI?

Dependency Injection (DI) is a bit of a difficult topic for somebody starting with Flutter.

While the need for and benefit of DI are undisputed, the same cannot be said for the tools used.

  • some use plain provider
  • some use get_it
  • some pair get_it with injectable to generate the boilerplate code
  • then there’s kiwi
  • others prefer to rely on the built-in DI-capabilities of whatever state-management framework they happen to be using, such as
    - BlocProvider for bloc users, or
    - Provider for riverpod users
  • where yet others combine them with some of the approaches listed earlier

Flutter just hasn’t yet had the time to crystallise a “gold standard” when it comes to dependency injection.

Bloc and Riverpod are overkill for a simple first app. I also hope that going without them first will help me understand Flutter better and make understanding Bloc and Riverpod easier, if/when the time comes.

That still leaves me with some options, but get_it and kiwi “feel” like they are working against the Flutter framework, rather than with it.

I do not want to circumvent Flutter, because doing it the native way

  • helps me understand Flutter — after all, it’s probably designed the way it is for a reason
  • hopefully will make it easier to understand auxilliary frameworks like Bloc and/or Riverpod when the time comes to look into them because they are built on top of providers
  • helps me understand how and why other approaches differ and what advantages / disadvantages they have
  • hopefully allows Flutter to do more optimisations

With that in mind, let’s do a deep dive into providers-based dependency injection.

I’ll also demonstrate the approach by rewriting the counter demo app we’ve been modifying so far.

The Flutter way of doing things

As a beginner, my understanding of “the Flutter way of doing things” is of course quite rough, but one thing I’ve already learned is that Flutter likes to pull shared state up into the parent widgets. Everything flows downwards.

So in the extreme case of global state, to borrow a picture from Flutter:

Fig.1: Flutter’s global state outlined

Remember, everything is a widget, in Flutter.

So what Fig.1 tells me is that there should be a parent widget somewhere upstream in the widget tree that provides the required services that the widgets further down the widget tree need.

In spite of popular belief, Flutter does actually come with dependency-injection capabilities out of the box. Those take the form of the BuildContext that can be queried for widgets of a particular type.

Widget Injection

But how do we get those widgets from upstream in the widget tree for us in our own widgets?

Finding a widget from upstream

Widgets query the BuildContext for an instance from upstream the widget tree, using e.g. BuildContext.dependOnInheritedWidgetOfExactType .

By convention, we wrap these calls into a static of / maybeOf call s.t. the initialisation logic can be hidden from the clients who can just call MyWidget.of(context).

This allows the class to define its own fallback logic in case there isn’t a widget in scope.

Typically,

  • maybeOf returns null if the widget isn’t found
  • of returns a non-nullable instance and throws an exception if none is found. Typically calls maybeOf internally.

And the return type doesn’t necessarily have to be the widget class on which the static methods are defined.

The documentation mentions:

For example, Theme is implemented as a StatelessWidget that builds a private inherited widget; Theme.of looks for that private inherited widget and then returns the ThemeData inside it.

And if you have trouble imagining it, here’s the source code for Theme.of :

static ThemeData of(BuildContext context) {
final _InheritedTheme? inheritedTheme = context.dependOnInheritedWidgetOfExactType<_InheritedTheme>();
final MaterialLocalizations? localizations = Localizations.of<MaterialLocalizations>(context, MaterialLocalizations);
final ScriptCategory category = localizations?.scriptCategory ?? ScriptCategory.englishLike;
final ThemeData theme = inheritedTheme?.theme.data ?? _kFallbackTheme;
return ThemeData.localize(theme, theme.typography.geometryThemeFor(category));
}

(cf. https://github.com/flutter/flutter/blob/bae5e49bc2/packages/flutter/lib/src/material/theme.dart#L104 )

Depending on inherited widgets

You’ve probably noticed the dependOnInheritedWidgetOfExactType , forgot that we briefly talked about them in the first part in this series and wondered “well what’s that now, then?” and what do they mean by “depend”?

This is where I remind you that I have a blog post on the life cycle of state objects and that you may want to look at it for a brief refresher of what happens when. Namely, the important thing to note is

State.didChangeDependencies is called once to initialise and then again when an InheritedWidget the State is dependent on changes or is moved.

How does a state know which widgets it needs to watch and how does the widget know to invoke whose states listeners?

Well, that’s where the InheritedWidget comes into place. It’s a widget that keeps track of which widgets “depend on” it and will cause those to be rebuilt when it itself changes.

Because this is much more useful than simply finding an arbitrary widget, flutter has optimised the finding functions for inherited widgets in constant time.

Both of these methods

  • run in O(1) time — “with small constant factor”, as the documentation tells us
  • should not be called from widget constructors or from State.initState, because those methods are not invoked again on a rebuild
  • should not be called from State.dispose because the element tree is no longer stable at that time
  • are safe to use from State.deactivate
  • are most often called fromState.build, State.didChangeDependencies or State.didUpdateWidget

(Note that using different methods to find a widget that isn’t an InheritedWidget [e.g. BuildContext.findAncestorStateOfType] runs in O(N) in the depth of the widget tree and isn’t safe to use from State.deactivate)

Depending on parts of an InheritedWidget

Getting rebuilt when anything about the InheritedWidget changes is sometimes overkill.

Often, you’re fine with everything else about a widget’s state changing, so long as that one thing that you actually care about stays the same. And there’s two subclasses that are designed to help with that.

InheritedModels are InheritedWidgets that expose a list of “aspects” that can be individually listened to.

InheritedListeners are InheritedWidgets that listen to a Listenable and update their dependents when

Small InheritedModel example

So knowing this, how would we use InheritedModels? I’ll use the example from the documentation where we have a logo that has a changable size (large or not) and a changeable background colour (any Color), but I will break it down and rewrite the code a bit, where I think it helps:

  1. define an enum to define the “aspects” of the widget that should be subscribable
enum LogoAspect { backgroundColor, large }

2. subclass InheritedModelwith that enum and add properties to reflect those aspects


class LogoModel extends InheritedModel<LogoAspect> {
const LogoModel({
super.key,
this.backgroundColor,
this.large,
required super.child,
});

final Color? backgroundColor;
final bool? large;

}

what is that required super.child ?

It’s the widget tree that actually contains the widgets somewhere downstream, that actually use the model. I.e. in this case, it’s very simple:

LogoModel(
backgroundColor: color,
large: large,
child: const BackgroundWidget(
child: LogoWidget(),
),
),

InheritedWidgets (including InheritedModels) extend ProxyWidget, which is a widget that does not itself build a widget but instead displays its child.

3. add static xyzOf(context) functions to properly

class LogoModel extends InheritedModel<LogoAspect> {
// constructor

final Color? backgroundColor;
final bool? large;

static Color? backgroundColorOf(BuildContext context) {
return InheritedModel
.inheritFrom<LogoModel>(context, aspect: LogoAspect.backgroundColor)
?.backgroundColor
;
}

static bool sizeOf(BuildContext context) {
return InheritedModel
.inheritFrom<LogoModel>(context, aspect: LogoAspect.large)
?.large
?? false
;
}
}

Note that InheritedModel.inheritFrom tries to depend on each LogoModel it finds upstream in turn until it finds one for which InheritedModel.isSupportedAspect is true. By default, that will be the first LogoModel it finds.

4. override updateShouldNotify method to determine when an update even needs to be propagated

class LogoModel extends InheritedModel<LogoAspect> {
// constructor

final Color? backgroundColor;
final bool? large;

// static xyzOf(context) functions

@override
bool updateShouldNotify(LogoModel oldWidget) {
var backgroundChanged = backgroundColor != oldWidget.backgroundColor;
var sizeChanged = large != oldWidget.large;
return backgroundChanged || sizeChanged;
}
}

5. implement updateShouldNotifyDependent to determine for each listener whether we need to actually call it

class LogoModel extends InheritedModel<LogoAspect> {
// constructor

final Color? backgroundColor;
final bool? large;

// xyzOf(context) functions

@override
bool updateShouldNotify(LogoModel oldWidget) {
var backgroundChanged = backgroundColor != oldWidget.backgroundColor;
var sizeChanged = large != oldWidget.large;
return backgroundChanged || sizeChanged;
}

@override
bool updateShouldNotifyDependent(
LogoModel oldWidget,
Set<LogoAspect> dependencies,
) {
return dependencies.any((element) => switch(element){
LogoAspect.backgroundColor => backgroundColor != oldWidget.backgroundColor,
LogoAspect.large => large != oldWidget.large,
});
}
}

The difference between updateShouldNotify and updateShouldNotifyDependent should be clear:

  • updateShouldNotify : “has something changed that requires me to notify a subscriber?”
  • updateShouldNotifyDependent : “has something changed that requires me to notify that subscriber?”

6. finally subscribe to the aspects

We have seen earlier, that the widget subtree starting from the LogoModel will look like

LogoModel(
backgroundColor: color,
large: large,
child: const BackgroundWidget(
child: LogoWidget(),
),
),

the BackgroundWidget and LogoWidget then use the xyzOf(context) methods to listen only to those changes that are relevant to them. I.e.

class LogoWidget extends StatelessWidget {
const LogoWidget({super.key});

@override
Widget build(BuildContext context) {
final bool largeLogo = LogoModel.sizeOf(context);

return AnimatedContainer(
padding: const EdgeInsets.all(20.0),
duration: const Duration(seconds: 2),
curve: Curves.fastLinearToSlowEaseIn,
alignment: Alignment.center,
child: FlutterLogo(size: largeLogo ? 200.0 : 100.0),
);
}
}

only listens to LogoModel.large because that’s all it needs to know about, and

class BackgroundWidget extends StatelessWidget {
const BackgroundWidget({super.key, required this.child});

final Widget child;

@override
Widget build(BuildContext context) {
final Color color = LogoModel.backgroundColorOf(context)!;

return AnimatedContainer(
padding: const EdgeInsets.all(12.0),
color: color,
duration: const Duration(seconds: 2),
curve: Curves.fastOutSlowIn,
child: child,
);
}
}

only listens to LogoModel.backgroundColor because that’s all it cares about.

Note, that even though LogoWidget and BackgroundWidget change based on the state contained in LogoModel, they are both StatelessWidgets.

7. actually build the widget tree

class InheritedModelExample extends StatefulWidget {
const InheritedModelExample({super.key});

@override
State<InheritedModelExample> createState() => _InheritedModelExampleState();
}

class _InheritedModelExampleState extends State<InheritedModelExample> {
bool large = false;
Color color = Colors.blue;

@override
Widget build(BuildContext context) {
return Scaffold(
body: LogoModel(
backgroundColor: color,
large: large,
child: const BackgroundWidget(
child: LogoWidget(),
),
),
);
}
}

Key thing to note, here:

  • the LogoModel itself is stateless and is switched out when the state changes

This confused me for a moment because widgets are supposed to be immutable. I.e. switching out the LogoModel here should also create a new BackgroundWidget with a new LogoWidget in the widget tree.

But then if they are created anew, what’s the point in subscribing to LogoModel changes by depending on it?

This is one of the instances where we need to remember that flutter language isn’t beginner friendly:

Widgets represent the configuration of Elements.
Each Element has a widget, specified in Element.widget. The term “widget” is often used when strictly speaking “element” would be more correct.

While an Element has a current Widget, over time, that widget may be replaced by others.

SO what I expect to happen is that:

  • the widgets do indeed get swapped out, but the elements do not.
  • Because the widget subtree under LogoModel is const, swapping out those widgets does not actually trigger a rebuild on that subtree.
  • Swapping out the LogoModel widget itself, however, triggers a change in its underlying element’s state,
  • which is then propagated to the elements instantiated by the BackgroundWidget and LogoWidget, because they declared a dependency on changed aspects of the LogoModel
  • which then triggers a rebuild on the element tree,
  • which then asks the widgets how to update itself

A bit confusing, but I think I’m starting to make sense of this.

Using providers

As you may have noticed, using InheritedWidget directly is a bit cumbersome.

The provider package was specifically designed to deal with that. Quote:

A wrapper around InheritedWidget to make them easier to use and more reusable.

By using provider instead of manually writing InheritedWidget, you get:

- simplified allocation/disposal of resources
- lazy-loading
- a vastly reduced boilerplate over making a new class every time
- devtool friendly — using Provider, the state of your application will be visible in the Flutter devtool
- a common way to consume these InheritedWidgets (See Provider.of/Consumer/Selector)
- increased scalability for classes with a listening mechanism that grows exponentially in complexity (such as ChangeNotifier, which is O(N) for dispatching notifications).

So let’s have a closer look at how providers simplify things.

Provider

The value held by an InheritedWidget must be final and constructor-initialised.

A Provider voids that limitation by exposing the value to the flutter lifecycle without having to create a full new subclass of a StatefulWidget.

class MyModel {
void dispose() {}
}

class MySomething extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Provider<MyModel>(
create: (context) => MyModel(),
dispose: (context, value) => value.dispose(),
child: ...,
);
}
}

Here, the value held by the provider is of type MyModel. It is initialised in the create function and when the provider is removed from the widget tree, a clean-up can be performed with the dispose function.

lazy loading

By default, the provider’s create function is called the first time the value is read — i.e. “lazily” — rather than when the provider is inserted into the.

To change that, you can set lazy: false in the Provider constructor.

Provider.of

Provider.of takes two arguments: a BuildContext and a listen boolean.

Provider(
create: (context) {
return MyModel(Provider.of<Something>(context, listen: false)),
},
)

Behind the scenes, providers use the InheritedWidget, so this function abstracts the two ways of getting such anInheritedWidget:

  • listen:true => dependOnInheritedWidgetOfExactType , which subscribes to changes
  • listen:false => getElementForInheritedWidgetOfExactType , which doesn’t

Important: if a provider cannot be found, an exception will be thrown.

Provider.value

If a value is already created, but not exposed, the Provider.value can be used to construct a provider that returns the value and does nothing when Provider.dispose is called.

final foo = MockFoo();

await tester.pumpWidget(
Provider<Foo>.value(
value: foo,
child: TestedWidget(),
),
);

The documentation notes on this particular example:

Since we used a mocked class (typically using mockito), [we] have to downcast the mock to the type of the mocked class. Otherwise, the type inference will resolve to Provider<MockFoo> instead of Provider<Foo>, which will cause Provider.of<Foo> to fail.

ChangeNotifier

In Flutter, Listenables are objects that provide notification there’s two main implementations of the interface:

  • ChangeNotifier : listeners are void callbacks
  • ValueNotifier : extends ChangeNotifier, listeners receive “current value” in the callbacks
class CounterModel with ChangeNotifier {
int _count = 0;
int get count => _count;

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

Important:

  • the ChangeNotifier can be mixed into a class, the ValueNotifier must be extended
  • ValueNotifier should be used with immutable values because it only notifies when its value’s identity changes
    (i.e. a ValueNotifier<List<int>> would not notify if its list‘s’ contents is changed rather than the list swapped out)

ChangeNotifierProvider

A ChangeNotifierProvider

  • listens to a ChangeNotifier
  • exposes it to descendants
  • rebuilds dependents on ChangeNotifier.notifyListeners

The ChangeNotifier is either

  • created in the ChangeNotifierProvider constructor’s create function
ChangeNotifierProvider(
create: (_) => new MyChangeNotifier(),
child: ...
)

or

  • re-used by being passed to the ChangeNotifierProvider.value constructor function
MyChangeNotifier variable;

ChangeNotifierProvider.value(
value: variable,
child: ...
)

Consumer

A Consumer<T> calls Provider.of<T>(context) and passes the so received value to its builder function.

It has two purposes:

  • obtain a value from a provider when we don’t have a BuildContext that is a descendant of said provider (and therefore cannot use Provider.of )
  • help performance optimization by providing more granular rebuilds

How?

Pass on provider

@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => Foo(),
child: Consumer<Foo>(
builder: (_, foo, __) => Text(foo.value),
},
);
}

Here, if we had put child: Text(Provider.of<Foo>(context)), the context instance provided to the build function cannot find the ChangeNotifierProvider created in its body. By using a Consumer, the builder function is called with the Consumer's BuildContext instance — which because it’s a descendant of the ChangeNotifierProvider, can find the provider.

Performance optimisations

 @override
Widget build(BuildContext context) {
return Consumer<Foo>(
builder: (context, foo, child) => FooWidget(foo: foo, child: child),
child: BarWidget(),
);
}

when Foo changes upstream, only the builder is rebuilt, not the child.

Selector

Selector is similar to Consumer in that it calls Provider.of on its context. But rather than passing the provided value on to the builder function directly (as a Consumer would), it first passes it on to a selector function that returns a value or a collection of values that the builder function then uses.

Selector<Foo, ({String item1, String item2})>(
selector: (_, foo) => (item1: foo.item1, item2: foo.item2),
builder: (_, data, __) {
return Text('${data.item1} ${data.item2}');
},
);

that way, builder is only called when at least one of the selected properties changed.

By default, it uses DeepCollectionEquality to determine whether the builder needs to be called again, but this can be changed by setting the shouldRebuild function in the constructor.

MultiProvider

Very simple: instead of

Provider<Foo>(
create: (_) => Foo(),
child : Consumer<Foo>(
builder: (context, foo, child) =>
Provider.value(value: foo.bar, child: child),
child: Provider<Baz>(
create: (_) => Baz(),
child: ...,
),
),
),

we can just do

MultiProvider(
providers: [
Provider(create: (_) => Foo()),
Consumer<Foo>(
builder: (context, foo, child) =>
Provider.value(value: foo.bar, child: child),
),
Provider(create: (_) => Baz()),
],
);

Other providers you should have heard of

An over-engineered example

So far, we’ve had a lot of theory, I think it’s time we tried to apply what we talked about.

We still have the counter demo app lying around, let’s make use of it and implement a very over-engineered example that makes use of as many providers as we can reasonably fit.

Fig.2: a simplified but somewhat over-engineered widget tree for a counter app

The idea is simple:

  • providers for globally available services are at the very top of the widget tree
  • the repository guards access to the data (here the count)
  • the service coordinates all repositories it needs (here just the one) to provide service functions to client models
  • the models connects UI functions to appropriate service calls and performs calculations on the results, if necessary

How would that look?

InMemoryCountRepository

abstract class CountRepository {
int getCount();

void incrementCount();
}

class InMemoryCountRepository implements CountRepository {
int _count = 0;

@override
int getCount() {
return _count;
}

@override
void incrementCount() {
_count++;
}
}

CountService

import 'count_repository.dart';

class CountService {
final CountRepository _countRepository;

const CountService({
required CountRepository countRepository,
}) : _countRepository = countRepository;

int getCount() => _countRepository.getCount();

void incrementCount() => _countRepository.incrementCount();
}

Sigh … I miss Kotlin. That constructor is ugly af (and it’s going to get even worse, the more parameters there are).
Sadly, all I can do is use this opportunity to raise awareness for the corresponding github issue.

Still, we get a CountRepository from the constructor and use it to provide service-level functions to the application.

Global

How do we get the repository into the service?

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'count_repository.dart';
import 'count_service.dart';

class Global extends StatelessWidget {
final Widget _child;

const Global({
super.key,
required Widget child,
}) : _child = child;

@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
Provider<CountRepository>(create: (_) => InMemoryCountRepository()),
Consumer<CountRepository>(
builder: (_, repository, child) => Provider(
create: (_) => CountService(countRepository: repository),
child: child,
),
),
],
child: _child,
);
}
}

Because Global creates the provider for the InMemoryCountRepository, the context it is given does not know of it. We need to use a Consumer and use its BuildContext in its builder function to retrieve the repository and construct our service provider.

Note that the CountService knows nothing of the InMemoryCountRepository, it just knows the CountRepository interface. We can swap out the implementation as we please, with zero changes to the CountService.

For this to work, both Provider and Consumer need to explicitly declare the <CountRepository>, otherwise the provider resolves to Provider<InMemoryCountRepository>, which cannot get picked up by a Consumer<CountRepository>.

CounterPageModel

Ok, so we have a service through which we can increment and get the count, we have a repository that implements the interaction with whatever is holding the count (here itself).

Now we need something that holds the state of the counter page. Namely, we need a message and a message level.

import 'package:flutter/material.dart';
import 'package:widgets/text/messagelevel.dart';
import 'count_service.dart';

class CounterPageModel with ChangeNotifier {
final CountService _countService;
int _count;

CounterPageModel({
required CountService countService,
}) : _countService = countService,
_count = countService.getCount();

String get message => '$_count';

MessageLevel get level => switch (_count % 10) {
0 => MessageLevel.warning,
_ => MessageLevel.info,
};

void increment() {
_countService.incrementCount();
_count = _countService.getCount();
notifyListeners();
}
}

Easy enough: everything is handled by the service. We fetch the current count from the service and calculate our

CounterPage

Now we need the actual page itself.

class CounterPageController with ChangeNotifier {
void increment() {
notifyListeners();
}
}

class CounterPage extends StatelessWidget {
final CounterPageController? controller;

const CounterPage({super.key, this.controller});

@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ChangeNotifierProvider(
create: (_) {
var model = CounterPageModel(
countService: Provider.of<CountService>(context),
);
controller?.addListener(() {
model.increment();
});
return model;
},
child: Selector<CounterPageModel, (String, MessageLevel)>(
selector: (_, model) => (model.message, model.level),
builder: (_, data, __) => InfoPanel(
message: data.$1,
level: data.$2,
),
),
)
],
),
);
}
}

I will explain why we need the CounterPageController in a moment.

For now, just note that the Selector could just as well be a Consumer, it’s just there to demonstrate how to use it.

The app

With all parts in place, we can now replace the default dummy app with our own:

import 'package:flutter/material.dart';
import 'package:tiger_tasks/provider_demo/global.dart';
import 'provider_demo/counter_page.dart';

void main() {
runApp(const MyApp());
}

class MyApp extends StatelessWidget {
const MyApp({super.key});

// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return Global(
child: MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(title: 'Provider-based Counter Demo'),
),
);
}
}

class MyHomePage extends StatelessWidget {
const MyHomePage({super.key, required this.title});

final String title;

@override
Widget build(BuildContext context) {
var controller = CounterPageController();
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(title),
),
body: CounterPage(
controller: controller,
),
floatingActionButton: FloatingActionButton(
onPressed: () {
controller.increment();
},
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}

Here, we have a FloatingActionButton that is not below the CounterPage. Therefore, it cannot use a consumer to get to the CounterPageModel encapsulated therein.

As was the case in the “Controlling the widget” section of part 5 in this series, this situation requires a controller.

Getting rid of the controller

If the FloatingActionButton had been part of the CounterPage, we wouldn’t need a controller. Can we move it there? Of course we can.

class CounterPage extends StatelessWidget {
const CounterPage({super.key});

@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => CounterPageModel(
countService: Provider.of<CountService>(context),
),
child: Stack(
alignment: AlignmentDirectional.bottomEnd,
children: [
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Selector<CounterPageModel, (String, MessageLevel)>(
selector: (_, model) => (model.message, model.level),
builder: (_, data, __) => InfoPanel(
message: data.$1,
level: data.$2,
),
),
],
),
),
Builder(
builder: (context) => Padding(
padding: const EdgeInsets.all(16),
child: FloatingActionButton(
onPressed: () {
Provider.of<CounterPageModel>(context, listen: false)
.increment();
},
tooltip: 'Increment',
child: const Icon(Icons.add),
),
),
),
],
),
);
}
}

The first thing to note is that the ChangeNotifierProvider moved to the very top of the sub-tree returend by the CounterPage's build function.

I think it’s good practice anyway to have the providers at the top of the sub-tree, but here it’s actually necessary so both the display and the floating button can access it.

Next, we use a Stack widget to lay the FloatingActionButton on top of the rest of the interface and use its alignment: property to control where we want to place our button.

Finally, we need to use the Builder widget because again, the context provided to the CounterPage's build function does not yet know about the provider created in the CounterPage.

Note, that with the controller out of the way, we can now declare the entire MyHomePage widget instance const in our app:

import 'package:flutter/material.dart';
import 'package:tiger_tasks/provider_demo/global.dart';
import 'provider_demo/counter_page.dart';

void main() {
runApp(const MyApp());
}

class MyApp extends StatelessWidget {
const MyApp({super.key});

// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return Global(
child: MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(title: 'Provider-based Counter Demo'),
),
);
}
}

class MyHomePage extends StatelessWidget {
const MyHomePage({super.key, required this.title});

final String title;

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(title),
),
body: const CounterPage(),
);
}
}

Side-note: update your stateful widgets

While implementing this, I had run into an interesting issue:

My notifiers were working as intended, my builder functions were being called, the correct values were propagated … but not displayed in the running app.

Fig.3: count in app ( `$1`) is correct, builder is called, app still shows `0`

This issue took a while for me to understand. Why doesn’t my widget get repainted to show the updated state?

The thing is … it does. The problem is that the state wasn’t updated.

I intentionally over-engineered the widget in part 5 of this series (and I’m quite thankful I did because it allowed me to run into this in a small example). The InfoPanel is stateful, even though it has no reason to be:

class _InfoPanelState extends State<InfoPanel> {
late InfoPanelController _controller;
late MessageLevel _level;
late String _message;

@override
void initState() {
assert(widget.message.isNotEmpty, 'InfoPanel message must not be empty.');

_level = widget.level;
_message = widget.message;

_controller = widget.controller ?? InfoPanelController();
_controller.addListener(() {
setState(() {
_level = _controller._level;
_message = _controller._message;
});
});

super.initState();
}

static const double _edgeInsets = 8;
static const double _borderRadius = 16;

@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(_edgeInsets),
decoration: BoxDecoration(
color: theme._backgroundColor(_level),
border: Border.all(color: theme._borderColor(_level)),
borderRadius: BorderRadius.circular(_borderRadius),
),
child: Text(
_message,
style: theme._messageStyle(_level),
),
);
}
}

the problem now was that when we rebuilt the InfoPanel widget, we updated the “initial state” on the widget (read in State.initState), but we never propagated that change to the _InfoPanelState.

What was missing was a State.didUpdateWidget implementation

@override
void didUpdateWidget(InfoPanel oldWidget) {
super.didUpdateWidget(oldWidget);
_level = widget.level;
_message = widget.message;
}

Conclusion

Providers are value wrappers that allow listeners to subscribe to updates to the values they wrap. They are built on top of Flutter’s InheritedWidgets and make working with them significantly easier.

Flutter comes with dependency injection capabilities that takes largely the form of querying the BuildContext for a particular provider. The querying

We have also seen that using a provider-based approach can significantly reduce the amount of stateful widgets we need to implement in our app.

When designing a widget, keep in mind:

  • always start with a provider (or MultiProvider) of your model(s), keep the UI in its child: property
  • the context given to the build function of the widget that creates the providers cannot be used to query for those providers (can only find other providers further up the widget tree)
  • use theBuilder widget (or thebuilder function of providers), where necessary, to access the BuildContext that contains the created providers
  • use controllers if you need to yield control up the widget tree
  • if you are forced to use controllers, double-check if there isn’t a better way to go about things. sometimes it’s fine, sometimes it’s a code smell
  • for stateful widgets, don’t forget to implement didDependenciesChange and didUpdateWidget, where appropriate

--

--

Tiger Asks...

🇨🇭-based Software Engineer with a lot of questions and some answers.