A Flutter package for sharing state between widgets, inspired by riverpod and get_it.
Given a generic Data
class, let's see how different state management options compare.
@immutable
class Data {
const Data(this.firstItem, [this.secondItem]);
final Object firstItem;
final Object? secondItem;
static const initial = Data('initial data');
}
(The ==
/hashCode
overrides could be added manually, or with a fancy macro!)
class _InheritedData extends InheritedWidget {
const _InheritedData({super.key, required this.data, required super.child});
final Data data;
@override
bool updateShouldNotify(MyData oldWidget) => data != oldWidget.data;
}
class MyData extends StatefulWidget {
const MyData({super.key, required this.child});
final Widget child;
static Data of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<_InheritedData>()!.data;
}
State<MyData> createState() => _MyDataState();
}
class _MyDataState extends State<MyData> {
Data _data = Data.initial;
@override
Widget build(BuildContext context) {
return _InheritedData(data: _data, child: widget.child);
}
}
Then the data can be accessed with
final data = MyData.of(context);
typedef MyData = ValueNotifier<Data>;
class MyWidget extends StatelessWidget {
const MyWidget({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => MyData(Data.initial),
child: child,
);
}
}
(flutter_bloc is very similar but requires extending Cubit<Data>
rather than making a typedef
.)
final data = context.watch<MyData>().value;
@riverpod
class MyData extends _$MyData {
@override
Data build() => Data.initial;
void update(Object firstItem, [Object? secondItem]) {
state = Data(firstItem, secondItem);
}
}
A final
, globally-scoped myDataProvider
object is created via code generation:
$ dart run build_runner watch
and accessed as follows:
final data = ref.watch(myDataProvider);
typedef MyData = ValueNotifier<Data>;
GetIt.I.registerSingleton(MyData(Data.initial));
final data = watchIt<MyData>().value;
final getMyData = Get.it(Data.initial);
final data = Ref.watch(getMyData);
InheritedWidget |
provider | bloc | riverpod | get_it | get_hooked | |
---|---|---|---|---|---|---|
shared state between widgets | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
supports scoping | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
optimized for performance | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
optimized for testability | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Tailored for Dart 3 | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Has a stable release | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
supports conditional subscriptions | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
integrated with Hooks | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
avoids type overlap | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
no context needed |
❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
no boilerplate/code generation needed | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ |
supports lazy-loading | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
supports auto-dispose | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
supports Animation s |
✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
Flutter & non-Flutter variants | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ |
Until version 1.0.0, you can expect breaking changes without prior warning.
Many packages on pub.dev have both a Flutter and a non-Flutter variant.
Flutter | generic |
---|---|
flutter_riverpod | riverpod |
flutter_bloc | bloc |
watch_it | get_it |
If you want a non-Flutter version of get_hooked, please open an issue and describe your use case.
In April 2021, flutter/flutter#71947
added a huge performance optimization to the ChangeNotifier
API.
This boosted Listenable
objects throughout the Flutter framework,
and the effects have stretched into other packages:
Listenable
objects.Then in February 2024, Dart introduced extension types, allowing for complete control of an API surface without incurring runtime performance costs.
November 2024: get_hooked is born.
extension type Get(Listenable hooked) {
// ...
}
This package makes it easier than ever before for a multitude of widgets to subscribe to a single
Animation
.
A tailor-made TickerProvider
allows animations to repeatedly attach & detach from BuildContext
s
based on how they're being used. A developer could prevent widget rebuilding entirely by
hooking them straight up to RenderObject
s.
final getAnimation = Get.vsync();
class _RenderMyAnimation extends RenderSliverAnimatedOpacity {
_RenderMyAnimation() : super(opacity: getAnimation.hooked);
}
Here, "scoping" is defined as a form of dependency injection, where a subset of widgets
(typically descendants of an InheritedWidget
) receive data by different means.
ProviderScope(
overrides: [myDataProvider.overrideWith(OtherDataClass.new)],
child: Consumer(builder: (context, ref, child) {
final data = ref.watch(myDataProvider);
// ...
}),
),
Ref.watch()
will watch a different Get object if an Override
is found in
an ancestor GetScope
widget.
GetScope(
overrides: [Override(getMyData, overrideWith: OtherDataClass.new)],
child: HookBuilder(builder: (context) {
final data = Ref.watch(getMyData);
// ...
}),
),
If the current context
has an ancestor GetScope
, building another scope
isn't necessary:
class MyWidget extends HookWidget {
const MyWidget({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
final newData = useState(Data.initial);
Ref.override(getMyData, () => newData); // Adds newData to the scope.
return Row(
children: [Text('$newData'), child],
);
}
}
If the child widget uses Ref.watch(getMyData)
, it will watch
the newData
by default.
Want to find out what's going on?\ No breakpoints, no print statements. Just type the name.
"Get" encapsulates a listenable object with an interface for easy updates and automatic lifecycle management.
Get
objects aren't necessary if the state isn't shared between widgets.\
This example shows how to make a button with a number that increases each time it's tapped:
class CounterButton extends HookWidget {
const CounterButton({super.key});
@override
Widget build(BuildContext context) {
final counter = useState(0);
return FilledButton(
onPressed: () {
counter.value += 1;
},
child: Text('counter value: ${counter.value}'),
);
}
}
But the following change would allow any widget to access this value:
final getCount = Get.it(0);
class CounterButton extends HookWidget {
const CounterButton({super.key});
@override
Widget build(BuildContext context) {
return FilledButton(
onPressed: () {
getCount.value += 1;
},
child: Text('counter value: ${Ref.watch(getCount)}'),
);
}
}
15 lines of code, same as before!
An object like getCount
can't be passed into a const
constructor.\
However: since access isn't limited in scope, it can be referenced by functions and static methods,
creating huge potential for rebuild-optimization.
The following example supports the same functionality as before, but the Text
widget updates based on
getCount
without the outer button widget ever being rebuilt:
final getCount = Get.it(0);
class CounterButton extends FilledButton {
const CounterButton({super.key})
: super(onPressed: _increment, child: const HookBuilder(builder: _build));
static void _increment() {
getCount.modify((int value) => value + 1);
}
static Widget _build(BuildContext context) {
return Text('counter value: ${Ref.watch(getCount)}');
}
}
Here's a (less oversimplified) rundown of the Get API:
extension type Get<T, V extends ValueListenable<T>>.custom(V hooked) {
@factory
static GetValue<T> it<T>(T initial) => GetValue<T>._(initial);
T get value => hooked.value
}
extension type GetValue<T>._(ValueNotifier<T> hooked) implements Get<T, ValueNotifier<T>> {
void emit(T newValue) => hooked.value = newValue;
void modify(T Function(T value) modifier) => emit(modifier(hooked.value));
}
class Ref {
static T watch(Get<T, ValueListenable<T>> getObject) {
return use(_RefHook(getObject));
}
}
[!CAUTION] Do not access
hooked
directly: get it through a Hook instead.\ If a listener is added without automatically being removed, it can result in memory leaks, and callingdispose()
would create problems for other widgets that are still using it.
Only use
hooked
in the following situations:
- If another API accepts a
Listenable
object (and takes care of the listener automatically).- If you feel like it.
Ref
functions, along with any function name starting with use
,
should only be called inside a HookWidget
's build method.
// BAD
Builder(builder: (context) {
final focusNode = useFocusNode();
final data = Ref.watch(useMyData);
})
// GOOD
HookBuilder(builder: (context) {
final focusNode = useFocusNode();
final data = Ref.watch(useMyData);
})
A HookWidget
's context
keeps track of:
Neither of these should change throughout the widget's lifetime.
For a more detailed explanation, see also:
Get
naming conventionsJust like how Hook functions generally start with use
, Get objects should start
with get
.
If the object is only intended to be used by widgets in the same .dart
file,
consider marking it with an annotation, to avoid cluttering autofill suggestions:
@visibleForTesting
final getAnimation = Get.vsync();
hooked
directlyUnlike most StatefulWidget
member variables, Get objects persist throughout
changes to the app's state, so a couple of missing removeListener()
calls
might create a noticeable performance impact.
Prefer calling Ref.watch()
to subscribe to updates.
When a GetAsync
object's listeners are removed, it will automatically end its
stream subscription and restore the listenable to its default state.
A listenable encapulated in a Get object should avoid calling the internal
ChangeNotifier.dispose()
method, since the object would be unusable
from that point onward.
So far, not a single person has reached out because of a problem with this package. Which means it's probably flawless!